mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-22 18:57:14 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f32f52293 | ||
|
|
e480f07c32 | ||
|
|
d551adc14c | ||
|
|
90fb977a10 | ||
|
|
05b712d084 | ||
|
|
60943420b4 | ||
|
|
3b1fbb73ce | ||
|
|
8f048efd69 | ||
|
|
0416359c65 | ||
|
|
f4497b3f0a | ||
|
|
d76cee8abe | ||
|
|
f4d936e620 | ||
|
|
18cc217c0d | ||
|
|
6199fce251 | ||
|
|
4ea28c03b6 | ||
|
|
ba8ec81735 | ||
|
|
d55486977d |
@@ -147,5 +147,15 @@ jobs:
|
|||||||
echo "::notice::catena-corpus@$version is already published"
|
echo "::notice::catena-corpus@$version is already published"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
# Staging offers a new version of a package that exists. It cannot
|
||||||
|
# bring one into being: the registry answers `POST /-/stage/package`
|
||||||
|
# with a 404 for a name it has never seen. So the first release of a
|
||||||
|
# new name is published by hand, with 2FA, and every release after
|
||||||
|
# it goes through here. Not a failure — PyPI is already out.
|
||||||
|
if ! npm view catena-corpus version >/dev/null 2>&1; then
|
||||||
|
echo "::warning::catena-corpus does not exist on npm yet, and staging cannot create it."
|
||||||
|
echo "::warning::Publish the first version by hand: npm publish crates/bindings/npm/dist --access public"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
cd crates/bindings/npm/dist && npm stage publish --access public
|
cd crates/bindings/npm/dist && npm stage publish --access public
|
||||||
echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish"
|
echo "::notice::staged catena-corpus@$version; approve it with 2FA to publish"
|
||||||
|
|||||||
Generated
+41
-25
@@ -101,7 +101,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "avplan"
|
name = "avplan"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -126,7 +126,7 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bibclean"
|
name = "bibclean"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -143,7 +143,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bibfetch"
|
name = "bibfetch"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -196,7 +196,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bookmeta"
|
name = "bookmeta"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -210,7 +210,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "booksniff"
|
name = "booksniff"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -250,12 +250,14 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "catena"
|
name = "catena"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bibclean",
|
"bibclean",
|
||||||
|
"bibfetch",
|
||||||
"booksniff",
|
"booksniff",
|
||||||
"chunkmd",
|
"chunkmd",
|
||||||
"citefind",
|
"citefind",
|
||||||
|
"colophon",
|
||||||
"corpus-core",
|
"corpus-core",
|
||||||
"cuekit",
|
"cuekit",
|
||||||
"cuepack",
|
"cuepack",
|
||||||
@@ -275,7 +277,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "catena-cli"
|
name = "catena-cli"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap",
|
"clap",
|
||||||
"clap_mangen",
|
"clap_mangen",
|
||||||
@@ -310,7 +312,7 @@ checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "chunkmd"
|
name = "chunkmd"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -332,7 +334,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "citefind"
|
name = "citefind"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"chunkmd",
|
"chunkmd",
|
||||||
@@ -395,6 +397,20 @@ dependencies = [
|
|||||||
"roff",
|
"roff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colophon"
|
||||||
|
version = "0.3.1"
|
||||||
|
dependencies = [
|
||||||
|
"bibclean",
|
||||||
|
"catena-cli",
|
||||||
|
"clap",
|
||||||
|
"corpus-core",
|
||||||
|
"regex",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -432,7 +448,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "corpus-core"
|
name = "corpus-core"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -476,7 +492,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cuekit"
|
name = "cuekit"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"corpus-core",
|
"corpus-core",
|
||||||
"regex",
|
"regex",
|
||||||
@@ -488,7 +504,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cuenorm"
|
name = "cuenorm"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -498,7 +514,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cuepack"
|
name = "cuepack"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -509,7 +525,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cueseg"
|
name = "cueseg"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -596,7 +612,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "emb"
|
name = "emb"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -860,7 +876,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idnorm"
|
name = "idnorm"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bibclean",
|
"bibclean",
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
@@ -926,7 +942,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langid"
|
name = "langid"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1006,7 +1022,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdscrub"
|
name = "mdscrub"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1018,7 +1034,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mdsplit"
|
name = "mdsplit"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1097,7 +1113,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdfgutter"
|
name = "pdfgutter"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1110,7 +1126,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pdftriage"
|
name = "pdftriage"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1354,7 +1370,7 @@ checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rrf"
|
name = "rrf"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1501,7 +1517,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "seriesof"
|
name = "seriesof"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
@@ -1618,7 +1634,7 @@ checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "textkit"
|
name = "textkit"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"regex",
|
"regex",
|
||||||
"tiktoken-rs",
|
"tiktoken-rs",
|
||||||
@@ -1708,7 +1724,7 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tocfind"
|
name = "tocfind"
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"catena-cli",
|
"catena-cli",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
+27
-26
@@ -3,38 +3,39 @@ resolver = "3"
|
|||||||
members = ["crates/*"]
|
members = ["crates/*"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.3.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
license = "MPL-2.0"
|
license = "MPL-2.0"
|
||||||
repository = "https://git.pumpin.online/funkpower/catena"
|
repository = "https://git.pumpin.online/funkpower/catena"
|
||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
catena-cli = { path = "crates/catena-cli", version = "0.1.0" }
|
catena-cli = { path = "crates/catena-cli", version = "0.3.1" }
|
||||||
corpus-core = { path = "crates/corpus-core", version = "0.1.0" }
|
corpus-core = { path = "crates/corpus-core", version = "0.3.1" }
|
||||||
cuekit = { path = "crates/cuekit", version = "0.1.0" }
|
cuekit = { path = "crates/cuekit", version = "0.3.1" }
|
||||||
textkit = { path = "crates/textkit", version = "0.1.0" }
|
textkit = { path = "crates/textkit", version = "0.3.1" }
|
||||||
chunkmd = { path = "crates/chunkmd", version = "0.1.0" }
|
chunkmd = { path = "crates/chunkmd", version = "0.3.1" }
|
||||||
mdscrub = { path = "crates/mdscrub", version = "0.1.0" }
|
mdscrub = { path = "crates/mdscrub", version = "0.3.1" }
|
||||||
mdsplit = { path = "crates/mdsplit", version = "0.1.0" }
|
mdsplit = { path = "crates/mdsplit", version = "0.3.1" }
|
||||||
pdftriage = { path = "crates/pdftriage", version = "0.1.0" }
|
pdftriage = { path = "crates/pdftriage", version = "0.3.1" }
|
||||||
cuenorm = { path = "crates/cuenorm", version = "0.1.0" }
|
cuenorm = { path = "crates/cuenorm", version = "0.3.1" }
|
||||||
cueseg = { path = "crates/cueseg", version = "0.1.0" }
|
cueseg = { path = "crates/cueseg", version = "0.3.1" }
|
||||||
rrf = { path = "crates/rrf", version = "0.1.0" }
|
rrf = { path = "crates/rrf", version = "0.3.1" }
|
||||||
cuepack = { path = "crates/cuepack", version = "0.1.0" }
|
cuepack = { path = "crates/cuepack", version = "0.3.1" }
|
||||||
bibclean = { path = "crates/bibclean", version = "0.1.0" }
|
bibclean = { path = "crates/bibclean", version = "0.3.1" }
|
||||||
avplan = { path = "crates/avplan", version = "0.1.0" }
|
avplan = { path = "crates/avplan", version = "0.3.1" }
|
||||||
pdfgutter = { path = "crates/pdfgutter", version = "0.1.0" }
|
pdfgutter = { path = "crates/pdfgutter", version = "0.3.1" }
|
||||||
catena = { path = "crates/bindings", version = "0.1.0" }
|
catena = { path = "crates/bindings", version = "0.3.1" }
|
||||||
seriesof = { path = "crates/seriesof", version = "0.1.0" }
|
seriesof = { path = "crates/seriesof", version = "0.3.1" }
|
||||||
idnorm = { path = "crates/idnorm", version = "0.1.0" }
|
idnorm = { path = "crates/idnorm", version = "0.3.1" }
|
||||||
bibfetch = { path = "crates/bibfetch", version = "0.1.0" }
|
bibfetch = { path = "crates/bibfetch", version = "0.3.1" }
|
||||||
bookmeta = { path = "crates/bookmeta", version = "0.1.0" }
|
bookmeta = { path = "crates/bookmeta", version = "0.3.1" }
|
||||||
langid = { path = "crates/langid", version = "0.1.0" }
|
langid = { path = "crates/langid", version = "0.3.1" }
|
||||||
tocfind = { path = "crates/tocfind", version = "0.1.0" }
|
tocfind = { path = "crates/tocfind", version = "0.3.1" }
|
||||||
booksniff = { path = "crates/booksniff", version = "0.1.0" }
|
booksniff = { path = "crates/booksniff", version = "0.3.1" }
|
||||||
citefind = { path = "crates/citefind", version = "0.1.0" }
|
citefind = { path = "crates/citefind", version = "0.3.1" }
|
||||||
emb = { path = "crates/emb", version = "0.1.0" }
|
colophon = { path = "crates/colophon", version = "0.3.1" }
|
||||||
|
emb = { path = "crates/emb", version = "0.3.1" }
|
||||||
|
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap_mangen = "0.3"
|
clap_mangen = "0.3"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ Each tool installs by name, so you can take one without taking the rest.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| [`booksniff`](crates/booksniff/README.md) | What a file actually is, whatever its name claims |
|
| [`booksniff`](crates/booksniff/README.md) | What a file actually is, whatever its name claims |
|
||||||
| [`bookmeta`](crates/bookmeta/README.md) | The metadata a file already carries |
|
| [`bookmeta`](crates/bookmeta/README.md) | The metadata a file already carries |
|
||||||
|
| [`colophon`](crates/colophon/README.md) | The record a book prints in its own front matter |
|
||||||
| [`tocfind`](crates/tocfind/README.md) | A book's own table of contents |
|
| [`tocfind`](crates/tocfind/README.md) | A book's own table of contents |
|
||||||
| [`langid`](crates/langid/README.md) | What language a document is in |
|
| [`langid`](crates/langid/README.md) | What language a document is in |
|
||||||
| [`pdfgutter`](crates/pdfgutter/README.md) | Books scanned two pages to a sheet, split back into pages |
|
| [`pdfgutter`](crates/pdfgutter/README.md) | Books scanned two pages to a sheet, split back into pages |
|
||||||
|
|||||||
@@ -67,7 +67,19 @@ pub fn title_case(title: &str) -> String {
|
|||||||
.to_lowercase()
|
.to_lowercase()
|
||||||
.trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\''])
|
.trim_end_matches(['.', ',', ':', ';', '!', '?', ')', ']', '"', '\''])
|
||||||
.to_string();
|
.to_string();
|
||||||
if SMALL_WORDS.binary_search(&core.as_str()).is_ok() && at > 0 && at < last {
|
// A small word stays small in the middle of a title, but not
|
||||||
|
// where a title actually starts. A colon starts a subtitle, and
|
||||||
|
// "Aesthetic in Kant: a Critique" is the giveaway that the rule
|
||||||
|
// was only ever counting from the beginning of the whole string.
|
||||||
|
let opens_a_subtitle = at > 0
|
||||||
|
&& words[at - 1]
|
||||||
|
.trim_end_matches(['"', '\'', ')', ']'])
|
||||||
|
.ends_with([':', ';', '?', '!', '.']);
|
||||||
|
if SMALL_WORDS.binary_search(&core.as_str()).is_ok()
|
||||||
|
&& at > 0
|
||||||
|
&& at < last
|
||||||
|
&& !opens_a_subtitle
|
||||||
|
{
|
||||||
return word.to_lowercase();
|
return word.to_lowercase();
|
||||||
}
|
}
|
||||||
let lowered = word.to_lowercase();
|
let lowered = word.to_lowercase();
|
||||||
@@ -218,3 +230,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod subtitle_case_tests {
|
||||||
|
use super::title_case;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_subtitle_starts_with_a_capital() {
|
||||||
|
// The rule counted small words from the start of the string, so every
|
||||||
|
// subtitle beginning "A", "The" or "Of" was set lower case.
|
||||||
|
assert_eq!(
|
||||||
|
title_case("AESTHETIC IN KANT: A CRITIQUE"),
|
||||||
|
"Aesthetic in Kant: A Critique"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
title_case("BORN FIGHTING: THE SCOTS-IRISH"),
|
||||||
|
"Born Fighting: The Scots-Irish"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
title_case("A HISTORY: AN INTRODUCTION TO IT"),
|
||||||
|
"A History: An Introduction to It"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_small_word_in_the_middle_is_still_small() {
|
||||||
|
assert_eq!(
|
||||||
|
title_case("THE RISE AND FALL OF THE THIRD REICH"),
|
||||||
|
"The Rise and Fall of the Third Reich"
|
||||||
|
);
|
||||||
|
assert_eq!(title_case("WAR OF THE WORLDS"), "War of the Worlds");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -260,9 +260,14 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn an_underscored_title_is_cleaned_before_it_is_cased() {
|
fn an_underscored_title_is_cleaned_before_it_is_cased() {
|
||||||
let cleaned = clean(&book("THE FALL_ AND THE RISE_txt", "Arendt, Hannah"));
|
let cleaned = clean(&book("THE FALL_ AND THE RISE_txt", "Arendt, Hannah"));
|
||||||
|
// "And" is capitalised because the colon starts a subtitle, and the
|
||||||
|
// first word of one is capitalised however small it is. This asserted
|
||||||
|
// "and" until the rule was found to be counting small words from the
|
||||||
|
// start of the whole string, which set every subtitle opening "A",
|
||||||
|
// "The" or "An" in lower case.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cleaned.book.title.as_deref(),
|
cleaned.book.title.as_deref(),
|
||||||
Some("The Fall: and the Rise")
|
Some("The Fall: And the Rise")
|
||||||
);
|
);
|
||||||
assert_eq!(cleaned.changes.len(), 2);
|
assert_eq!(cleaned.changes.len(), 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,20 @@ is a failure — nobody replied, and the identifier may be perfectly good.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `doi` | Crossref | It assigns most scholarly DOIs |
|
| `doi` | Crossref | It assigns most scholarly DOIs |
|
||||||
| `arxiv` | the arXiv | Its own register of preprints |
|
| `arxiv` | the arXiv | Its own register of preprints |
|
||||||
| `isbn` | OpenLibrary | Books, without a key |
|
| `isbn` | OpenLibrary's edition record, then its index, 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
|
||||||
|
assigned by a publisher and recorded by whoever gets round to it, so an
|
||||||
|
answer that names the book but not who wrote it is common — OpenLibrary's
|
||||||
|
edition record names its authors by key rather than by name, and its index
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
An **ISSN** has no register that answers without a key, so it is not looked up
|
An **ISSN** has no register that answers without a key, so it is not looked up
|
||||||
on its own. A journal's ISSN comes back on the works published in it instead.
|
on its own. A journal's ISSN comes back on the works published in it instead.
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
//! Asking the registers, which is the part that touches the network.
|
||||||
|
//!
|
||||||
|
//! Lifted out of the command line so that the Python bindings ask in exactly
|
||||||
|
//! the same order, with the same retries and the same backoff. Two
|
||||||
|
//! implementations of "which register answers about this identifier" would
|
||||||
|
//! drift, and the one nobody runs from a terminal would drift silently.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::{Error, Record, Source};
|
||||||
|
|
||||||
|
/// How to ask: who to say you are, how long to wait, how often to retry.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Ask {
|
||||||
|
/// An address the registers ask for, and answer faster when they get.
|
||||||
|
pub polite: Option<String>,
|
||||||
|
/// How long to wait on any one register.
|
||||||
|
pub timeout: Duration,
|
||||||
|
/// How many times to try a register that is unreachable.
|
||||||
|
pub attempts: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Ask {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
polite: None,
|
||||||
|
timeout: crate::TIMEOUT,
|
||||||
|
attempts: crate::ATTEMPTS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ask {
|
||||||
|
/// An agent configured to ask this way.
|
||||||
|
pub fn agent(&self) -> ureq::Agent {
|
||||||
|
ureq::Agent::config_builder()
|
||||||
|
.timeout_global(Some(self.timeout))
|
||||||
|
.user_agent(user_agent(self.polite.as_deref()))
|
||||||
|
.build()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask every register that answers about this kind of identifier, in turn,
|
||||||
|
/// and stop at the first complete answer.
|
||||||
|
///
|
||||||
|
/// An incomplete answer is kept in case nothing better comes: a title with no
|
||||||
|
/// authors is still more than nothing, and a register being down is only a
|
||||||
|
/// failure when every register is. `Ok(None)` means no register here answers
|
||||||
|
/// about that kind at all.
|
||||||
|
pub fn lookup(
|
||||||
|
agent: &ureq::Agent,
|
||||||
|
id: &str,
|
||||||
|
kind: &str,
|
||||||
|
attempts: u32,
|
||||||
|
) -> Result<Option<Record>, Error> {
|
||||||
|
let chain = Source::chain_for_kind(kind);
|
||||||
|
if chain.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let mut best: Option<Record> = None;
|
||||||
|
let mut last_error = None;
|
||||||
|
for source in chain {
|
||||||
|
match ask(agent, *source, id, kind, attempts) {
|
||||||
|
Ok(record) => {
|
||||||
|
match &mut best {
|
||||||
|
// Merged rather than replaced. The registers answer about
|
||||||
|
// different parts of one book: OpenLibrary's edition record
|
||||||
|
// carries the publisher, the pagination and the language
|
||||||
|
// and names its authors by key, while its search index
|
||||||
|
// carries the authors and not the publisher. Keeping
|
||||||
|
// whichever arrived first throws the other half away.
|
||||||
|
Some(kept) => kept.fill_from(record),
|
||||||
|
None => best = Some(record),
|
||||||
|
}
|
||||||
|
if best.as_ref().is_some_and(Record::complete) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(why) => last_error = Some(why),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match best {
|
||||||
|
Some(record) => Ok(Some(record)),
|
||||||
|
None => {
|
||||||
|
Err(last_error.unwrap_or_else(|| Error::Unreachable("no register answered".into())))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask a register, retrying a failure that might not be one.
|
||||||
|
///
|
||||||
|
/// Waits a second before the second attempt and two before the third: long
|
||||||
|
/// enough for a register that is rate-limiting, short enough that one which
|
||||||
|
/// is simply down does not hold up a pipeline.
|
||||||
|
fn ask(
|
||||||
|
agent: &ureq::Agent,
|
||||||
|
source: Source,
|
||||||
|
id: &str,
|
||||||
|
kind: &str,
|
||||||
|
attempts: u32,
|
||||||
|
) -> Result<crate::Record, Error> {
|
||||||
|
let mut last = Error::Unreachable("no attempt was made".into());
|
||||||
|
|
||||||
|
for attempt in 0..attempts.max(1) {
|
||||||
|
match fetch(agent, source, id, kind) {
|
||||||
|
Ok(record) => return Ok(record),
|
||||||
|
// A shape this cannot read will not read any better next time.
|
||||||
|
Err(e @ Error::Shape(_)) => return Err(e),
|
||||||
|
Err(e) => last = e,
|
||||||
|
}
|
||||||
|
std::thread::sleep(Duration::from_secs(1 << attempt));
|
||||||
|
}
|
||||||
|
Err(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fetch(
|
||||||
|
agent: &ureq::Agent,
|
||||||
|
source: Source,
|
||||||
|
id: &str,
|
||||||
|
kind: &str,
|
||||||
|
) -> Result<crate::Record, Error> {
|
||||||
|
let response = agent.get(source.url(id)).call();
|
||||||
|
let mut response = match response {
|
||||||
|
Ok(response) => response,
|
||||||
|
// A register that does not have it says so with a status, and that is
|
||||||
|
// an answer rather than a failure.
|
||||||
|
Err(ureq::Error::StatusCode(404)) => return Ok(crate::missing(id, kind, source)),
|
||||||
|
Err(e) => return Err(Error::Unreachable(e.to_string())),
|
||||||
|
};
|
||||||
|
let body = response
|
||||||
|
.body_mut()
|
||||||
|
.read_to_string()
|
||||||
|
.map_err(|e| Error::Unreachable(e.to_string()))?;
|
||||||
|
source.read(id, kind, &body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Who is asking.
|
||||||
|
///
|
||||||
|
/// The registers ask for an address and answer faster when they get one; a
|
||||||
|
/// tool that does not say who it is gets the slow pool.
|
||||||
|
fn user_agent(polite: Option<&str>) -> String {
|
||||||
|
let version = env!("CARGO_PKG_VERSION");
|
||||||
|
match polite {
|
||||||
|
Some(address) => format!("bibfetch/{version} (mailto:{address})"),
|
||||||
|
None => format!("bibfetch/{version}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
//! book. There is no register for an ISSN that answers without a key, so an
|
//! book. There is no register for an ISSN that answers without a key, so an
|
||||||
//! ISSN is looked up as the journal of a work rather than on its own.
|
//! ISSN is looked up as the journal of a work rather than on its own.
|
||||||
|
|
||||||
|
pub mod ask;
|
||||||
pub mod source;
|
pub mod source;
|
||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -58,6 +59,21 @@ pub struct Record {
|
|||||||
/// The abstract, where the register carries one.
|
/// The abstract, where the register carries one.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub summary: Option<String>,
|
pub summary: Option<String>,
|
||||||
|
/// The subject headings the register files it under, as it words them.
|
||||||
|
///
|
||||||
|
/// A register states these for far more books than print a cataloguing
|
||||||
|
/// block, which makes them the second source a shelf otherwise lacks.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub subjects: Vec<String>,
|
||||||
|
/// The language it is written in, as the register gives it: a two- or
|
||||||
|
/// three-letter code, or a name. Not normalised here — what a register
|
||||||
|
/// said is the record, and deciding that "eng" and "English" are one
|
||||||
|
/// thing belongs to whoever is filing them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub language: Option<String>,
|
||||||
|
/// How many pages the register says it has.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pages: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn version() -> u32 {
|
fn version() -> u32 {
|
||||||
@@ -111,6 +127,70 @@ pub fn missing(id: &str, kind: &str, source: Source) -> Record {
|
|||||||
year: None,
|
year: None,
|
||||||
container: None,
|
container: None,
|
||||||
summary: None,
|
summary: None,
|
||||||
|
subjects: Vec::new(),
|
||||||
|
language: None,
|
||||||
|
pages: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Record {
|
||||||
|
/// Whether this record says everything a catalogue wants.
|
||||||
|
///
|
||||||
|
/// Used to decide when to stop asking. Title and authors are the minimum
|
||||||
|
/// that makes a record usable; the rest is worth another register only
|
||||||
|
/// while one is left to ask.
|
||||||
|
pub fn complete(&self) -> bool {
|
||||||
|
self.found
|
||||||
|
&& self.title.is_some()
|
||||||
|
&& !self.authors.is_empty()
|
||||||
|
&& self.container.is_some()
|
||||||
|
&& self.language.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Take from another record whatever this one does not have.
|
||||||
|
///
|
||||||
|
/// The registers are complementary rather than redundant, and asking them
|
||||||
|
/// in turn and keeping one answer throws away the difference. OpenLibrary's
|
||||||
|
/// edition record gives the publisher, the pagination and the language but
|
||||||
|
/// names its authors by key; its search index gives the authors but not the
|
||||||
|
/// publisher. One book, two half-answers, and the whole of it only if they
|
||||||
|
/// are put together.
|
||||||
|
///
|
||||||
|
/// Nothing already answered is overwritten, so the order of the chain is
|
||||||
|
/// the order of trust.
|
||||||
|
pub fn fill_from(&mut self, other: Record) {
|
||||||
|
if !other.found {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.found = true;
|
||||||
|
if self.title.is_none() {
|
||||||
|
self.title = other.title;
|
||||||
|
}
|
||||||
|
if self.authors.is_empty() {
|
||||||
|
self.authors = other.authors;
|
||||||
|
}
|
||||||
|
if self.year.is_none() {
|
||||||
|
self.year = other.year;
|
||||||
|
}
|
||||||
|
if self.container.is_none() {
|
||||||
|
self.container = other.container;
|
||||||
|
}
|
||||||
|
if self.summary.is_none() {
|
||||||
|
self.summary = other.summary;
|
||||||
|
}
|
||||||
|
if self.subjects.is_empty() {
|
||||||
|
self.subjects = other.subjects;
|
||||||
|
}
|
||||||
|
if self.language.is_none() {
|
||||||
|
self.language = other.language;
|
||||||
|
}
|
||||||
|
if self.pages.is_none() {
|
||||||
|
self.pages = other.pages;
|
||||||
|
}
|
||||||
|
// Which register answered becomes which registers did.
|
||||||
|
if !self.source.split('+').any(|s| s == other.source) {
|
||||||
|
self.source = format!("{}+{}", self.source, other.source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,3 +214,74 @@ mod tests {
|
|||||||
assert!(json.contains("\"found\":false"), "{json}");
|
assert!(json.contains("\"found\":false"), "{json}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod merge_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn rec(source: &str) -> Record {
|
||||||
|
let mut r = missing("9780199205592", "isbn", Source::OpenLibrary);
|
||||||
|
r.source = source.to_string();
|
||||||
|
r.found = true;
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn two_half_answers_make_one_record() {
|
||||||
|
// Exactly what the two OpenLibrary endpoints give for one book: the
|
||||||
|
// edition record has the publisher and the language and names its
|
||||||
|
// authors by key; the index has the authors and not the publisher.
|
||||||
|
let mut edition = rec("openlibrary");
|
||||||
|
edition.title = Some("The First World War: A Very Short Introduction".into());
|
||||||
|
edition.container = Some("Oxford University Press".into());
|
||||||
|
edition.language = Some("eng".into());
|
||||||
|
|
||||||
|
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));
|
||||||
|
// The fuller title arrived first and is not displaced by the shorter.
|
||||||
|
assert_eq!(
|
||||||
|
edition.title.as_deref(),
|
||||||
|
Some("The First World War: A Very Short Introduction")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_register_that_has_nothing_contributes_nothing() {
|
||||||
|
let mut kept = rec("openlibrary");
|
||||||
|
kept.title = Some("A Book".into());
|
||||||
|
let absent = missing("9780199205592", "isbn", Source::GoogleBooks);
|
||||||
|
kept.fill_from(absent);
|
||||||
|
assert_eq!(kept.title.as_deref(), Some("A Book"));
|
||||||
|
assert_eq!(kept.source, "openlibrary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_record_names_every_register_that_answered() {
|
||||||
|
let mut a = rec("openlibrary");
|
||||||
|
a.title = Some("A Book".into());
|
||||||
|
let mut b = rec("googlebooks");
|
||||||
|
b.found = true;
|
||||||
|
b.pages = Some(134);
|
||||||
|
a.fill_from(b);
|
||||||
|
assert_eq!(a.source, "openlibrary+googlebooks");
|
||||||
|
assert_eq!(a.pages, Some(134));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn complete_means_a_catalogue_has_what_it_needs() {
|
||||||
|
let mut r = rec("openlibrary");
|
||||||
|
assert!(!r.complete());
|
||||||
|
r.title = Some("A Book".into());
|
||||||
|
r.authors = vec!["Someone".into()];
|
||||||
|
assert!(!r.complete(), "still missing the publisher and language");
|
||||||
|
r.container = Some("A Press".into());
|
||||||
|
r.language = Some("eng".into());
|
||||||
|
assert!(r.complete());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+10
-71
@@ -4,7 +4,6 @@ use std::path::PathBuf;
|
|||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bibfetch::{Error, Source};
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use corpus_core::exit;
|
use corpus_core::exit;
|
||||||
|
|
||||||
@@ -65,11 +64,12 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let agent: ureq::Agent = ureq::Agent::config_builder()
|
let agent = bibfetch::ask::Ask {
|
||||||
.timeout_global(Some(Duration::from_secs(args.timeout)))
|
polite: args.polite.clone(),
|
||||||
.user_agent(user_agent(args.polite.as_deref()))
|
timeout: Duration::from_secs(args.timeout),
|
||||||
.build()
|
attempts: args.attempts,
|
||||||
.into();
|
}
|
||||||
|
.agent();
|
||||||
|
|
||||||
let mut records = Vec::new();
|
let mut records = Vec::new();
|
||||||
let mut missing = 0usize;
|
let mut missing = 0usize;
|
||||||
@@ -86,14 +86,12 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(source) = Source::for_kind(&kind) else {
|
match bibfetch::ask::lookup(&agent, &id, &kind, args.attempts) {
|
||||||
|
Ok(None) => {
|
||||||
eprintln!("bibfetch: {id}: no register here answers about {kind} identifiers");
|
eprintln!("bibfetch: {id}: no register here answers about {kind} identifiers");
|
||||||
missing += 1;
|
missing += 1;
|
||||||
continue;
|
}
|
||||||
};
|
Ok(Some(record)) => {
|
||||||
|
|
||||||
match ask(&agent, source, &id, &kind, args.attempts) {
|
|
||||||
Ok(record) => {
|
|
||||||
if !record.found {
|
if !record.found {
|
||||||
missing += 1;
|
missing += 1;
|
||||||
}
|
}
|
||||||
@@ -116,65 +114,6 @@ fn main() -> ExitCode {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask a register, retrying a failure that might not be one.
|
|
||||||
///
|
|
||||||
/// Waits a second before the second attempt and two before the third: long
|
|
||||||
/// enough for a register that is rate-limiting, short enough that one which
|
|
||||||
/// is simply down does not hold up a pipeline.
|
|
||||||
fn ask(
|
|
||||||
agent: &ureq::Agent,
|
|
||||||
source: Source,
|
|
||||||
id: &str,
|
|
||||||
kind: &str,
|
|
||||||
attempts: u32,
|
|
||||||
) -> Result<bibfetch::Record, Error> {
|
|
||||||
let mut last = Error::Unreachable("no attempt was made".into());
|
|
||||||
|
|
||||||
for attempt in 0..attempts.max(1) {
|
|
||||||
match fetch(agent, source, id, kind) {
|
|
||||||
Ok(record) => return Ok(record),
|
|
||||||
// A shape this cannot read will not read any better next time.
|
|
||||||
Err(e @ Error::Shape(_)) => return Err(e),
|
|
||||||
Err(e) => last = e,
|
|
||||||
}
|
|
||||||
std::thread::sleep(Duration::from_secs(1 << attempt));
|
|
||||||
}
|
|
||||||
Err(last)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn fetch(
|
|
||||||
agent: &ureq::Agent,
|
|
||||||
source: Source,
|
|
||||||
id: &str,
|
|
||||||
kind: &str,
|
|
||||||
) -> Result<bibfetch::Record, Error> {
|
|
||||||
let response = agent.get(source.url(id)).call();
|
|
||||||
let mut response = match response {
|
|
||||||
Ok(response) => response,
|
|
||||||
// A register that does not have it says so with a status, and that is
|
|
||||||
// an answer rather than a failure.
|
|
||||||
Err(ureq::Error::StatusCode(404)) => return Ok(bibfetch::missing(id, kind, source)),
|
|
||||||
Err(e) => return Err(Error::Unreachable(e.to_string())),
|
|
||||||
};
|
|
||||||
let body = response
|
|
||||||
.body_mut()
|
|
||||||
.read_to_string()
|
|
||||||
.map_err(|e| Error::Unreachable(e.to_string()))?;
|
|
||||||
source.read(id, kind, &body)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Who is asking.
|
|
||||||
///
|
|
||||||
/// The registers ask for an address and answer faster when they get one; a
|
|
||||||
/// tool that does not say who it is gets the slow pool.
|
|
||||||
fn user_agent(polite: Option<&str>) -> String {
|
|
||||||
let version = env!("CARGO_PKG_VERSION");
|
|
||||||
match polite {
|
|
||||||
Some(address) => format!("bibfetch/{version} (mailto:{address})"),
|
|
||||||
None => format!("bibfetch/{version}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The identifier and its kind out of one record.
|
/// The identifier and its kind out of one record.
|
||||||
fn read_identifier(line: &str) -> Result<(String, String), String> {
|
fn read_identifier(line: &str) -> Result<(String, String), String> {
|
||||||
let value: serde_json::Value =
|
let value: serde_json::Value =
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ pub enum Source {
|
|||||||
Arxiv,
|
Arxiv,
|
||||||
/// OpenLibrary, for books by ISBN.
|
/// OpenLibrary, for books by ISBN.
|
||||||
OpenLibrary,
|
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 {
|
impl Source {
|
||||||
@@ -26,16 +33,42 @@ impl Source {
|
|||||||
Source::Crossref => "crossref",
|
Source::Crossref => "crossref",
|
||||||
Source::Arxiv => "arxiv",
|
Source::Arxiv => "arxiv",
|
||||||
Source::OpenLibrary => "openlibrary",
|
Source::OpenLibrary => "openlibrary",
|
||||||
|
Source::OpenLibrarySearch => "openlibrary",
|
||||||
|
Source::GoogleBooks => "googlebooks",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which register answers about a kind of identifier.
|
/// Which register answers about a kind of identifier.
|
||||||
pub fn for_kind(kind: &str) -> Option<Self> {
|
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 {
|
match kind {
|
||||||
"doi" => Some(Source::Crossref),
|
"doi" => &[Source::Crossref],
|
||||||
"arxiv" => Some(Source::Arxiv),
|
"arxiv" => &[Source::Arxiv],
|
||||||
"isbn" => Some(Source::OpenLibrary),
|
// The index first. Asked for the fields by name it answers with
|
||||||
_ => None,
|
// 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,
|
||||||
|
],
|
||||||
|
_ => &[],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +83,18 @@ impl Source {
|
|||||||
Source::OpenLibrary => {
|
Source::OpenLibrary => {
|
||||||
format!("https://openlibrary.org/isbn/{}.json", encode(id))
|
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.
|
||||||
|
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)
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +104,8 @@ impl Source {
|
|||||||
Source::Crossref => crossref(id, kind, body),
|
Source::Crossref => crossref(id, kind, body),
|
||||||
Source::Arxiv => arxiv(id, kind, body),
|
Source::Arxiv => arxiv(id, kind, body),
|
||||||
Source::OpenLibrary => open_library(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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,9 +233,24 @@ struct OpenLibraryBook {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
subtitle: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
publish_date: Option<String>,
|
publish_date: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
publishers: Vec<String>,
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct OpenLibraryKey {
|
||||||
|
#[serde(default)]
|
||||||
|
key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
||||||
@@ -196,10 +258,139 @@ fn open_library(id: &str, kind: &str, body: &str) -> Result<Record, Error> {
|
|||||||
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
serde_json::from_str(body).map_err(|e| Error::Shape(e.to_string()))?;
|
||||||
|
|
||||||
let mut record = blank(id, kind, Source::OpenLibrary);
|
let mut record = blank(id, kind, Source::OpenLibrary);
|
||||||
record.title = book.title;
|
// 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();
|
record.container = book.publishers.into_iter().next();
|
||||||
// The date is free text — "1999", "March 1999", "1999-03-01".
|
// The date is free text — "1999", "March 1999", "1999-03-01".
|
||||||
record.year = book.publish_date.as_deref().and_then(year_in);
|
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;
|
||||||
|
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.
|
||||||
|
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)]
|
||||||
|
struct GoogleReply {
|
||||||
|
#[serde(default)]
|
||||||
|
items: Vec<GoogleItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GoogleItem {
|
||||||
|
#[serde(rename = "volumeInfo", default)]
|
||||||
|
volume_info: GoogleVolume,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
|
};
|
||||||
|
let volume = item.volume_info;
|
||||||
|
|
||||||
|
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;
|
||||||
Ok(record)
|
Ok(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +446,10 @@ mod tests {
|
|||||||
fn each_kind_goes_to_the_body_that_assigns_it() {
|
fn each_kind_goes_to_the_body_that_assigns_it() {
|
||||||
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
|
assert_eq!(Source::for_kind("doi"), Some(Source::Crossref));
|
||||||
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
|
assert_eq!(Source::for_kind("arxiv"), Some(Source::Arxiv));
|
||||||
assert_eq!(Source::for_kind("isbn"), Some(Source::OpenLibrary));
|
// 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.
|
// No register answers about an ISSN without a key.
|
||||||
assert_eq!(Source::for_kind("issn"), None);
|
assert_eq!(Source::for_kind("issn"), None);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ crate-type = ["cdylib", "rlib"]
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
booksniff.workspace = true
|
booksniff.workspace = true
|
||||||
bibclean.workspace = true
|
bibclean.workspace = true
|
||||||
|
colophon.workspace = true
|
||||||
chunkmd.workspace = true
|
chunkmd.workspace = true
|
||||||
citefind.workspace = true
|
citefind.workspace = true
|
||||||
corpus-core.workspace = true
|
corpus-core.workspace = true
|
||||||
@@ -47,6 +48,11 @@ textkit.workspace = true
|
|||||||
|
|
||||||
pyo3 = { workspace = true, optional = true }
|
pyo3 = { workspace = true, optional = true }
|
||||||
|
|
||||||
|
# Only the Python build gets this. bibfetch is the one tool here that opens a
|
||||||
|
# socket, and its HTTP client does not build for wasm32 — so a dependency that
|
||||||
|
# is unconditional would take the JavaScript package down with it.
|
||||||
|
bibfetch = { workspace = true, optional = true }
|
||||||
|
|
||||||
# The JavaScript build. Only ever compiled for a wasm32 target; on any other
|
# The JavaScript build. Only ever compiled for a wasm32 target; on any other
|
||||||
# the module is compiled out and these sit unused.
|
# the module is compiled out and these sit unused.
|
||||||
wasm-bindgen = { workspace = true, optional = true }
|
wasm-bindgen = { workspace = true, optional = true }
|
||||||
@@ -56,7 +62,7 @@ serde-wasm-bindgen = { workspace = true, optional = true }
|
|||||||
# Only maturin turns this on. pyo3's extension-module feature leaves the
|
# Only maturin turns this on. pyo3's extension-module feature leaves the
|
||||||
# Python symbols to be resolved by the interpreter at load time, so an
|
# Python symbols to be resolved by the interpreter at load time, so an
|
||||||
# ordinary `cargo build` would fail to link if it were on by default.
|
# ordinary `cargo build` would fail to link if it were on by default.
|
||||||
python = ["dep:pyo3"]
|
python = ["dep:pyo3", "dep:bibfetch"]
|
||||||
# The C ABI. Off by default so an ordinary build does not also export a
|
# The C ABI. Off by default so an ordinary build does not also export a
|
||||||
# hundred symbols nobody asked for.
|
# hundred symbols nobody asked for.
|
||||||
capi = []
|
capi = []
|
||||||
|
|||||||
@@ -16,10 +16,12 @@ from ._catena import (
|
|||||||
chunk_markdown,
|
chunk_markdown,
|
||||||
citation_prompt,
|
citation_prompt,
|
||||||
clean_record,
|
clean_record,
|
||||||
|
colophon_prompt,
|
||||||
clean_underscores,
|
clean_underscores,
|
||||||
compose_subjects,
|
compose_subjects,
|
||||||
count_tokens,
|
count_tokens,
|
||||||
cue_windows,
|
cue_windows,
|
||||||
|
fetch_identifier,
|
||||||
find_citations,
|
find_citations,
|
||||||
fuse_hits,
|
fuse_hits,
|
||||||
identify,
|
identify,
|
||||||
@@ -29,16 +31,19 @@ from ._catena import (
|
|||||||
looks_like_footnote_citations,
|
looks_like_footnote_citations,
|
||||||
looks_like_person,
|
looks_like_person,
|
||||||
merge_citation_reply,
|
merge_citation_reply,
|
||||||
|
merge_colophon,
|
||||||
needs_subject,
|
needs_subject,
|
||||||
pack_cues,
|
pack_cues,
|
||||||
parse_identifier,
|
parse_identifier,
|
||||||
parse_subtitles,
|
parse_subtitles,
|
||||||
|
read_colophon,
|
||||||
record_problems,
|
record_problems,
|
||||||
record_severity,
|
record_severity,
|
||||||
same_work,
|
same_work,
|
||||||
scrub,
|
scrub,
|
||||||
segment_cues,
|
segment_cues,
|
||||||
series_of,
|
series_of,
|
||||||
|
shelve,
|
||||||
split_collection,
|
split_collection,
|
||||||
suggest_author,
|
suggest_author,
|
||||||
title_case,
|
title_case,
|
||||||
@@ -52,10 +57,12 @@ __all__ = [
|
|||||||
"chunk_markdown",
|
"chunk_markdown",
|
||||||
"citation_prompt",
|
"citation_prompt",
|
||||||
"clean_record",
|
"clean_record",
|
||||||
|
"colophon_prompt",
|
||||||
"clean_underscores",
|
"clean_underscores",
|
||||||
"compose_subjects",
|
"compose_subjects",
|
||||||
"count_tokens",
|
"count_tokens",
|
||||||
"cue_windows",
|
"cue_windows",
|
||||||
|
"fetch_identifier",
|
||||||
"find_citations",
|
"find_citations",
|
||||||
"fuse_hits",
|
"fuse_hits",
|
||||||
"identify",
|
"identify",
|
||||||
@@ -65,16 +72,19 @@ __all__ = [
|
|||||||
"looks_like_footnote_citations",
|
"looks_like_footnote_citations",
|
||||||
"looks_like_person",
|
"looks_like_person",
|
||||||
"merge_citation_reply",
|
"merge_citation_reply",
|
||||||
|
"merge_colophon",
|
||||||
"needs_subject",
|
"needs_subject",
|
||||||
"pack_cues",
|
"pack_cues",
|
||||||
"parse_identifier",
|
"parse_identifier",
|
||||||
"parse_subtitles",
|
"parse_subtitles",
|
||||||
|
"read_colophon",
|
||||||
"record_problems",
|
"record_problems",
|
||||||
"record_severity",
|
"record_severity",
|
||||||
"same_work",
|
"same_work",
|
||||||
"scrub",
|
"scrub",
|
||||||
"segment_cues",
|
"segment_cues",
|
||||||
"series_of",
|
"series_of",
|
||||||
|
"shelve",
|
||||||
"split_collection",
|
"split_collection",
|
||||||
"suggest_author",
|
"suggest_author",
|
||||||
"title_case",
|
"title_case",
|
||||||
|
|||||||
@@ -75,3 +75,49 @@ def valid_isbn(text: str) -> bool: ...
|
|||||||
def isbn_from_text(text: str) -> str | None: ...
|
def isbn_from_text(text: str) -> str | None: ...
|
||||||
def isbn_from_filename(name: str) -> str | None: ...
|
def isbn_from_filename(name: str) -> str | None: ...
|
||||||
def same_work(ours: str, theirs: str) -> bool: ...
|
def same_work(ours: str, theirs: str) -> bool: ...
|
||||||
|
|
||||||
|
def read_colophon(
|
||||||
|
text: str, pages: int = ..., shelf: str | None = ...
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""What a book prints about itself in its opening pages.
|
||||||
|
|
||||||
|
`shelf` is a closed list of headings to file the printed subjects onto,
|
||||||
|
one per line, optionally `Heading = term, term`.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def shelve(shelf: str, subjects: list[str]) -> tuple[list[str], list[str]]:
|
||||||
|
"""File subject headings onto a shelf. Returns (shelved, unshelved)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def fetch_identifier(
|
||||||
|
id: str,
|
||||||
|
kind: str = ...,
|
||||||
|
polite: str | None = ...,
|
||||||
|
timeout: int = ...,
|
||||||
|
attempts: int = ...,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
"""Ask the register that assigns an identifier what it knows about one.
|
||||||
|
|
||||||
|
Blocks on the network. `None` when no register answers about that kind.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def colophon_prompt(
|
||||||
|
record: dict[str, Any], text: str, pages: int = ...
|
||||||
|
) -> str | None:
|
||||||
|
"""The question to put to a model about what the pages did not settle.
|
||||||
|
|
||||||
|
None when the pages settled everything. Nothing is sent anywhere.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
|
def merge_colophon(
|
||||||
|
record: dict[str, Any], reply: str
|
||||||
|
) -> tuple[dict[str, Any], int]:
|
||||||
|
"""A model's reply folded in. Returns (record, fields_settled).
|
||||||
|
|
||||||
|
Only fills what the pages left unsettled; a model never overwrites what a
|
||||||
|
book printed about itself.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|||||||
@@ -431,5 +431,125 @@ fn _catena(module: &Bound<'_, PyModule>) -> PyResult<()> {
|
|||||||
module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?;
|
module.add_function(wrap_pyfunction!(merge_citation_reply, module)?)?;
|
||||||
module.add_function(wrap_pyfunction!(series_of, module)?)?;
|
module.add_function(wrap_pyfunction!(series_of, module)?)?;
|
||||||
module.add_function(wrap_pyfunction!(parse_identifier, module)?)?;
|
module.add_function(wrap_pyfunction!(parse_identifier, module)?)?;
|
||||||
|
module.add_function(wrap_pyfunction!(read_colophon, module)?)?;
|
||||||
|
module.add_function(wrap_pyfunction!(shelve, module)?)?;
|
||||||
|
module.add_function(wrap_pyfunction!(fetch_identifier, module)?)?;
|
||||||
|
module.add_function(wrap_pyfunction!(colophon_prompt, module)?)?;
|
||||||
|
module.add_function(wrap_pyfunction!(merge_colophon, module)?)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ───────────────────── what a book says about itself ──────────────────────
|
||||||
|
|
||||||
|
/// Read what a book prints about itself in its opening pages.
|
||||||
|
///
|
||||||
|
/// `shelf` is the closed list of headings to file the printed subjects onto,
|
||||||
|
/// in the format `colophon --shelf` takes: one heading per line, optionally
|
||||||
|
/// `Heading = term, term`. Without it the subjects come back as the book
|
||||||
|
/// printed them and nothing is filed.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (text, pages = colophon::PAGES, shelf = None))]
|
||||||
|
fn read_colophon<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
text: String,
|
||||||
|
pages: usize,
|
||||||
|
shelf: Option<String>,
|
||||||
|
) -> PyResult<Bound<'py, PyAny>> {
|
||||||
|
if pages == 0 {
|
||||||
|
return Err(PyValueError::new_err("pages = 0 would read nothing"));
|
||||||
|
}
|
||||||
|
// Reading a book's front matter is several milliseconds of pure
|
||||||
|
// computation on a few hundred kilobytes, and holding the interpreter for
|
||||||
|
// it makes `asyncio.to_thread` a lie: a caller reading a library several
|
||||||
|
// books at a time got no overlap at all until this let go.
|
||||||
|
let printed = py.detach(move || {
|
||||||
|
let shelf = shelf.as_deref().map(colophon::shelf::Shelf::parse);
|
||||||
|
colophon::read_onto(&text, pages, shelf.as_ref())
|
||||||
|
});
|
||||||
|
to_py(py, &printed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File subject headings onto a shelf without reading a book.
|
||||||
|
///
|
||||||
|
/// For the headings a catalogue already holds: the same matching the printed
|
||||||
|
/// ones get, so a record's existing subjects and its book's agree about what
|
||||||
|
/// counts as a heading. Returns `(shelved, unshelved)`.
|
||||||
|
#[pyfunction]
|
||||||
|
fn shelve(shelf: &str, subjects: Vec<String>) -> (Vec<String>, Vec<String>) {
|
||||||
|
let filed = colophon::shelf::Shelf::parse(shelf).file(&subjects);
|
||||||
|
(filed.on, filed.unfiled)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────── the registers ───────────────────────────────
|
||||||
|
|
||||||
|
/// Ask the body that assigns an identifier what it knows about one.
|
||||||
|
///
|
||||||
|
/// Crossref for a DOI, the arXiv for a preprint, OpenLibrary and Google Books
|
||||||
|
/// for an ISBN, asked in turn until one gives a complete answer. This is the
|
||||||
|
/// only function here that touches the network, and it blocks: call it from a
|
||||||
|
/// thread, or with `asyncio.to_thread`.
|
||||||
|
///
|
||||||
|
/// Returns the record, or `None` when no register here answers about that kind
|
||||||
|
/// of identifier. Raises when every register that should answer was
|
||||||
|
/// unreachable, because that is a different thing from the book not existing.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (id, kind = "isbn", polite = None, timeout = 20, attempts = 3))]
|
||||||
|
fn fetch_identifier<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
id: &str,
|
||||||
|
kind: &str,
|
||||||
|
polite: Option<String>,
|
||||||
|
timeout: u64,
|
||||||
|
attempts: u32,
|
||||||
|
) -> PyResult<Option<Bound<'py, PyAny>>> {
|
||||||
|
let ask = bibfetch::ask::Ask {
|
||||||
|
polite,
|
||||||
|
timeout: std::time::Duration::from_secs(timeout.max(1)),
|
||||||
|
attempts: attempts.max(1),
|
||||||
|
};
|
||||||
|
let agent = ask.agent();
|
||||||
|
// The GIL is not needed while waiting on a socket, and holding it there
|
||||||
|
// would stop every other thread in the interpreter for the whole request.
|
||||||
|
let found = py
|
||||||
|
.detach(|| bibfetch::ask::lookup(&agent, id, kind, ask.attempts))
|
||||||
|
.map_err(|e| PyValueError::new_err(e.to_string()))?;
|
||||||
|
match found {
|
||||||
|
Some(record) => Ok(Some(to_py(py, &record)?)),
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The question to put to a model about the fields a book's pages did not
|
||||||
|
/// settle, or `None` when they settled everything.
|
||||||
|
///
|
||||||
|
/// Nothing is sent anywhere: this composes the text and hands it back, so the
|
||||||
|
/// caller decides which model, on whose hardware, at what cost. The reply goes
|
||||||
|
/// to `merge_colophon`.
|
||||||
|
#[pyfunction]
|
||||||
|
#[pyo3(signature = (record, text, pages = colophon::PAGES))]
|
||||||
|
fn colophon_prompt(
|
||||||
|
record: &Bound<'_, PyAny>,
|
||||||
|
text: &str,
|
||||||
|
pages: usize,
|
||||||
|
) -> PyResult<Option<String>> {
|
||||||
|
let printed: colophon::Printed = from_py(record)?;
|
||||||
|
Ok(colophon::prompt::compose(&printed, text, pages))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A model's reply folded into the record the pages gave.
|
||||||
|
///
|
||||||
|
/// Only the fields the pages left unsettled are filled: a model cannot
|
||||||
|
/// overwrite something a book printed about itself, which is the whole reason
|
||||||
|
/// the pages are read first. Returns the completed record and how many fields
|
||||||
|
/// the reply actually settled.
|
||||||
|
#[pyfunction]
|
||||||
|
fn merge_colophon<'py>(
|
||||||
|
py: Python<'py>,
|
||||||
|
record: &Bound<'py, PyAny>,
|
||||||
|
reply: &str,
|
||||||
|
) -> PyResult<(Bound<'py, PyAny>, usize)> {
|
||||||
|
let mut printed: colophon::Printed = from_py(record)?;
|
||||||
|
let filled = colophon::prompt::merge(&mut printed, reply)
|
||||||
|
.map_err(|e| PyValueError::new_err(e.to_string()))?;
|
||||||
|
Ok((to_py(py, &printed)?, filled))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "colophon"
|
||||||
|
description = "The record a book prints in its own front matter."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
catena-cli.workspace = true
|
||||||
|
clap.workspace = true
|
||||||
|
corpus-core.workspace = true
|
||||||
|
bibclean.workspace = true
|
||||||
|
regex.workspace = true
|
||||||
|
schemars.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# colophon
|
||||||
|
|
||||||
|
The record a book prints in its own front matter.
|
||||||
|
|
||||||
|
Part of [catena](../../README.md).
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
A book states what it is twice before the text starts. The title page carries
|
||||||
|
the title as the publisher set it and the name of whoever wrote it. The
|
||||||
|
copyright page carries the imprint, the year, the ISBN, and — in most
|
||||||
|
English-language books printed since the 1970s — the cataloguing block, which
|
||||||
|
is a catalogue record a librarian made from the manuscript, subject headings
|
||||||
|
and all.
|
||||||
|
|
||||||
|
So this reads the pages rather than the file. A PDF whose embedded title is
|
||||||
|
`Untitled` and whose filename says `Oxford` opens on *The First World War: A
|
||||||
|
Very Short Introduction*, by Michael Howard, and that is what comes back.
|
||||||
|
|
||||||
|
Every field names the page it was read off. Nothing is inferred: a scan that
|
||||||
|
begins at the preface printed no title page, and the record of that is an
|
||||||
|
empty one with every field listed in `unsettled`.
|
||||||
|
|
||||||
|
## A shelf of your own
|
||||||
|
|
||||||
|
A cataloguing block states subjects the way a national library does:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Political science – Early works to 1800
|
||||||
|
English literature—Middle English, 1100–1500—History and criticism
|
||||||
|
```
|
||||||
|
|
||||||
|
Nobody browses by those. `--shelf` takes the closed list you actually file by
|
||||||
|
and returns the headings from it that the book's own subjects hit:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
colophon --shelf shelf.txt book.md
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
# one heading per line; `= terms` gives the words that match it,
|
||||||
|
# and a heading with no terms matches its own name.
|
||||||
|
Byzantine History = byzantium, byzantine, constantinople
|
||||||
|
Political Science = political science, politics
|
||||||
|
Ethics
|
||||||
|
```
|
||||||
|
|
||||||
|
The longest term wins, so `Ancient Philosophy` beats `Philosophy` and a period
|
||||||
|
of thought does not collapse into its parent. Matching is on whole words after
|
||||||
|
punctuation is folded away, because a block separates with em dashes, en
|
||||||
|
dashes, full stops and hyphens, and OCR turns any of those into any other.
|
||||||
|
|
||||||
|
Headings that match nothing come back under `unshelved`, kept as printed. A
|
||||||
|
subject quietly dropped reads exactly like a book that printed none.
|
||||||
|
|
||||||
|
## When you would use it
|
||||||
|
|
||||||
|
- **Cataloguing scans and secondhand files**, where the embedded metadata is
|
||||||
|
whatever the last tool to touch the file happened to write.
|
||||||
|
- **Checking a catalogue against the books themselves.** A record that
|
||||||
|
disagrees with the book's own title page is a record worth looking at.
|
||||||
|
- **Recovering an ISBN** that only exists in print, which turns a book with
|
||||||
|
no identifier into one [`bibfetch`](../bibfetch/README.md) can look up.
|
||||||
|
- **Getting subject headings** that nobody had to invent, from the
|
||||||
|
cataloguing block of the books that print one.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo install --git https://git.pumpin.online/funkpower/catena colophon
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
```
|
||||||
|
$ colophon first-world-war.md
|
||||||
|
{"v":1,"path":"first-world-war.md","title":"The First World War","subtitle":"A Very Short Introduction","authors":["Michael Howard"],"editors":[],"isbns":["9780192803535"],"publisher":"Oxford University Press","place":"Oxford","year":2002,"subjects":[],"read_from":{"authors":"title_page","isbns":"copyright_page","publisher":"copyright_page","subtitle":"title_page","title":"title_page","year":"copyright_page"},"unsettled":["subjects"],"pages_read":16}
|
||||||
|
```
|
||||||
|
|
||||||
|
A library at once, and the books that say nothing about themselves:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
colophon library/*.md | jq -r 'select(.title == null) | .path'
|
||||||
|
```
|
||||||
|
|
||||||
|
An ISBN that only exists in print, looked up:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch --polite you@example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
## What it will not read
|
||||||
|
|
||||||
|
Two pages in a book's front matter look like a title page and are not, and
|
||||||
|
both of them are how a catalogue ends up naming the wrong book. They are
|
||||||
|
found and refused.
|
||||||
|
|
||||||
|
The **series advertisement** lists other titles in the same series in the
|
||||||
|
same shape a title page uses — a work in capitals, a person beside it —
|
||||||
|
sometimes for four pages, with only the first saying what it is. The
|
||||||
|
**uniform title** in a translation's cataloguing block is the original
|
||||||
|
title, printed in brackets above the real one as a filing heading.
|
||||||
|
|
||||||
|
## Asking a model about the rest
|
||||||
|
|
||||||
|
Roughly a third of books print nothing this can read: an extraction that
|
||||||
|
starts at chapter one has no front matter in it at all. `--prompt` writes a
|
||||||
|
question about exactly the fields left `unsettled`, and `--merge` folds the
|
||||||
|
answer back in.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
colophon --prompt book.md | ollama run qwen3:8b > reply.json
|
||||||
|
colophon --merge reply.json book.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is sent from here: the question goes to stdout, and the model is
|
||||||
|
yours. What a model answers is marked `model` in `read_from`, so a record
|
||||||
|
always says which of its fields were read off a page and which were
|
||||||
|
answered.
|
||||||
|
|
||||||
|
The pages the question shows have had the advertising taken out of them
|
||||||
|
first. Handing a model a series list is how a book becomes catalogued as
|
||||||
|
whichever of two hundred other titles the model's eye fell on.
|
||||||
|
|
||||||
|
## Where this stops
|
||||||
|
|
||||||
|
It reports what the book printed, not what is true. `THE HUMAN CONDITION` is
|
||||||
|
reported in the capitals it was set in, and straightening that is
|
||||||
|
[`bibclean`](../bibclean/README.md)'s job. What a file's own metadata claims
|
||||||
|
is [`bookmeta`](../bookmeta/README.md)'s. What a register says about the
|
||||||
|
ISBN is [`bibfetch`](../bibfetch/README.md)'s. Which series a book belongs to
|
||||||
|
is [`seriesof`](../seriesof/README.md)'s.
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
| Option | Effect |
|
||||||
|
|---|---|
|
||||||
|
| `--pages <COUNT>` | How many pages in from the front to read. Default `16` |
|
||||||
|
| `--prompt` | Write the question to put to a model about what the pages left unsettled |
|
||||||
|
| `--merge <FILE>` | Fill in from a model's reply and write the completed record |
|
||||||
|
| `--quiet` | Say nothing; the exit code is the answer |
|
||||||
|
| `--man` | Write this page as a manual page, in roff |
|
||||||
|
|
||||||
|
## Exit codes
|
||||||
|
|
||||||
|
| Code | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `0` | The book printed something about itself |
|
||||||
|
| `1` | It printed nothing this could read |
|
||||||
|
| `65` | The input was not readable as text |
|
||||||
|
| `74` | The file could not be read |
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
//! The cataloguing-in-publication block, which is a catalogue record the
|
||||||
|
//! publisher printed inside the book.
|
||||||
|
//!
|
||||||
|
//! Since the 1970s most English-language publishers have printed the record
|
||||||
|
//! the Library of Congress made from the manuscript on the copyright page,
|
||||||
|
//! and since 2013 they have printed it in labelled fields. Either way it
|
||||||
|
//! names the author as a library files him, the title with its subtitle, the
|
||||||
|
//! ISBN, and the subject headings the book was classified under — which is
|
||||||
|
//! the one place in a book where its subjects are stated rather than
|
||||||
|
//! inferred.
|
||||||
|
//!
|
||||||
|
//! It is a record, so it is read as one. Nothing here guesses.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// What the block said.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Cip {
|
||||||
|
/// Title, without its subtitle.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// Subtitle, where the title statement had one.
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
/// Authors, in reading order rather than filing order.
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
/// Editors, where the work is an edited one.
|
||||||
|
pub editors: Vec<String>,
|
||||||
|
/// ISBNs printed inside the block.
|
||||||
|
pub isbns: Vec<String>,
|
||||||
|
/// Subject headings the book was classified under.
|
||||||
|
pub subjects: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cip {
|
||||||
|
fn is_empty(&self) -> bool {
|
||||||
|
self.title.is_none() && self.authors.is_empty() && self.subjects.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static HEAD: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)catalog(?:u)?ing[-\s]in[-\s]publication(?:\s+data)?").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `B105.C477D46 1991` / `126—dc20` — the call number, which ends the block.
|
||||||
|
static CALL_NUMBER: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^([A-Z]{1,3}\d{1,4}(\.\w+)*\s+\d{4}|\d{3}(\.\d+)?\s*[—–-]{1,2}\s*dc\d{2})")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A name as a library files it: `Howard, Michael, 1922- author.`
|
||||||
|
static FILED_NAME: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"^(?P<last>[A-Z][\p{L}'’\-]+(?:\s+[A-Z][\p{L}'’\-]+)?),\s+(?P<first>[A-Z][\p{L}'’\-\.]*(?:\s+[A-Z][\p{L}'’\-\.]*){0,3})\
|
||||||
|
(?:,\s*(?:\d{4}\s*[-–]\s*\d{0,4}|b\.\s*\d{4}|d\.\s*\d{4}))?\
|
||||||
|
(?:,?\s*(?:author|editor|translator|compiler)s?\.?)?\.?$",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `[Sur la traduction. English]` — the uniform title of a translation,
|
||||||
|
/// printed above the title it was translated into.
|
||||||
|
static UNIFORM_TITLE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^\\?\[.*\\?\]\.?$").expect("a valid pattern"));
|
||||||
|
static LABEL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(names|title|other titles|description|identifiers|subjects|classification|uniform title)\s*:\s*(.*)$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `1. Consciousness. 2. Philosophy of mind. I. Title.`
|
||||||
|
static NUMBERED_SUBJECTS: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^\s*1\.\s+\S").expect("a valid pattern"));
|
||||||
|
static SUBJECT_SPLIT: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\s*\b\d{1,2}\.\s+").expect("a valid pattern"));
|
||||||
|
/// The added entries a subject list ends with: `I. Title. II. Series.`
|
||||||
|
static ADDED_ENTRY: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\s+[IVX]+\.\s+.*$").expect("a valid pattern"));
|
||||||
|
static ISBN_LINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)isbn[:\s]*([0-9][0-9\s\-–]{8,20}[0-9Xx])").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// The responsibility statement's separator, and what OCR makes of it.
|
||||||
|
///
|
||||||
|
/// A slash between the title and who wrote it is a solidus in a serif face,
|
||||||
|
/// and page scanners read it as a capital I or a lowercase l about as often
|
||||||
|
/// as they read it correctly.
|
||||||
|
static RESPONSIBILITY: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"\s+[/Il\\|]\s+").expect("a valid pattern"));
|
||||||
|
static EDITION_TAIL: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)\s*[.,]?\s*[—–-]{1,2}\s*(\d+(st|nd|rd|th)|first|second|third|new|rev\.?|revised)\b.*$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn tidy(s: &str) -> String {
|
||||||
|
s.trim()
|
||||||
|
.trim_matches(|c: char| c == '\\' || c == '"')
|
||||||
|
.trim()
|
||||||
|
.trim_end_matches(['.', ',', ';', ':', '/'])
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a statement of responsibility into who wrote and who edited.
|
||||||
|
fn people(statement: &str) -> (Vec<String>, Vec<String>) {
|
||||||
|
let mut authors = Vec::new();
|
||||||
|
let mut editors = Vec::new();
|
||||||
|
for part in statement.split(';') {
|
||||||
|
let part = part.trim();
|
||||||
|
let lower = part.to_lowercase();
|
||||||
|
let (target, rest) = if let Some(rest) = lower.strip_prefix("edited by ") {
|
||||||
|
(&mut editors, &part[part.len() - rest.len()..])
|
||||||
|
} else if lower.starts_with("translated by ")
|
||||||
|
|| lower.starts_with("with ")
|
||||||
|
|| lower.starts_with("foreword by ")
|
||||||
|
|| lower.starts_with("introduction by ")
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
} else {
|
||||||
|
(&mut authors, part)
|
||||||
|
};
|
||||||
|
for name in rest.split(" and ").flat_map(|n| n.split(',')) {
|
||||||
|
let name = tidy(name);
|
||||||
|
if name.len() >= 3 && name.split_whitespace().count() <= 5 {
|
||||||
|
target.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(authors, editors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn filed_to_reading(line: &str) -> Option<String> {
|
||||||
|
let caps = FILED_NAME.captures(line)?;
|
||||||
|
Some(format!(
|
||||||
|
"{} {}",
|
||||||
|
tidy(caps.name("first")?.as_str()),
|
||||||
|
tidy(caps.name("last")?.as_str())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_title(statement: &str) -> (Option<String>, Option<String>, Vec<String>, Vec<String>) {
|
||||||
|
let mut pieces = RESPONSIBILITY.splitn(statement, 2);
|
||||||
|
let title_part = pieces.next().unwrap_or_default();
|
||||||
|
let (authors, editors) = pieces.next().map(people).unwrap_or_default();
|
||||||
|
let title_part = EDITION_TAIL.replace(title_part, "");
|
||||||
|
let mut halves = title_part.splitn(2, " : ");
|
||||||
|
let title = tidy(halves.next().unwrap_or_default());
|
||||||
|
let subtitle = halves.next().map(tidy).filter(|s| !s.is_empty());
|
||||||
|
(
|
||||||
|
Some(title).filter(|t| t.len() >= 2),
|
||||||
|
subtitle,
|
||||||
|
authors,
|
||||||
|
editors,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subjects_from(line: &str) -> Vec<String> {
|
||||||
|
let trimmed = ADDED_ENTRY.replace(line, "");
|
||||||
|
SUBJECT_SPLIT
|
||||||
|
.split(&trimmed)
|
||||||
|
.map(tidy)
|
||||||
|
.filter(|s| s.len() >= 3 && s.split_whitespace().count() <= 12)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn isbns_from(line: &str) -> Vec<String> {
|
||||||
|
ISBN_LINE
|
||||||
|
.captures_iter(line)
|
||||||
|
.filter_map(|c| {
|
||||||
|
let digits = bibclean::isbn::digits(&c[1]);
|
||||||
|
bibclean::isbn::valid(&digits).then_some(digits)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the CIP block out of a book's front matter, if it printed one.
|
||||||
|
pub fn read(front: &str) -> Option<Cip> {
|
||||||
|
let start = HEAD.find(front)?.end();
|
||||||
|
let mut block: Vec<&str> = Vec::new();
|
||||||
|
for line in front[start..].lines().skip(1) {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
if block.len() > 2 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if CALL_NUMBER.is_match(line) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
block.push(line);
|
||||||
|
if block.len() >= 24 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if block.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cip = Cip::default();
|
||||||
|
let labelled = block.iter().any(|l| {
|
||||||
|
LABEL
|
||||||
|
.captures(l)
|
||||||
|
.is_some_and(|c| matches!(c[1].to_lowercase().as_str(), "names" | "title" | "subjects"))
|
||||||
|
});
|
||||||
|
|
||||||
|
if labelled {
|
||||||
|
for line in &block {
|
||||||
|
let Some(caps) = LABEL.captures(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let value = caps[2].trim();
|
||||||
|
match caps[1].to_lowercase().as_str() {
|
||||||
|
"names" => {
|
||||||
|
for name in value.split('|') {
|
||||||
|
if let Some(read) = filed_to_reading(tidy(name).as_str()) {
|
||||||
|
cip.authors.push(read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"title" => {
|
||||||
|
let (title, subtitle, authors, editors) = split_title(value);
|
||||||
|
cip.title = title;
|
||||||
|
cip.subtitle = subtitle;
|
||||||
|
if cip.authors.is_empty() {
|
||||||
|
cip.authors = authors;
|
||||||
|
}
|
||||||
|
cip.editors = editors;
|
||||||
|
}
|
||||||
|
"identifiers" => cip.isbns.extend(isbns_from(value)),
|
||||||
|
"subjects" => {
|
||||||
|
let value = value.trim_start_matches("LCSH:").trim();
|
||||||
|
for subject in value.split('|') {
|
||||||
|
let subject = tidy(subject);
|
||||||
|
if subject.len() >= 3 {
|
||||||
|
cip.subjects.push(subject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut seen_title = false;
|
||||||
|
for line in &block {
|
||||||
|
if UNIFORM_TITLE.is_match(line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if !seen_title {
|
||||||
|
if let Some(name) = filed_to_reading(line) {
|
||||||
|
cip.authors.push(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ISBN_LINE.is_match(line) || line.to_lowercase().starts_with("isbn") {
|
||||||
|
cip.isbns.extend(isbns_from(line));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if RESPONSIBILITY.is_match(line) || line.contains(" : ") {
|
||||||
|
let (title, subtitle, authors, editors) = split_title(line);
|
||||||
|
cip.title = title;
|
||||||
|
cip.subtitle = subtitle;
|
||||||
|
if cip.authors.is_empty() {
|
||||||
|
cip.authors = authors;
|
||||||
|
}
|
||||||
|
cip.editors = editors;
|
||||||
|
seen_title = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if NUMBERED_SUBJECTS.is_match(line) {
|
||||||
|
cip.subjects = subjects_from(line);
|
||||||
|
}
|
||||||
|
cip.isbns.extend(isbns_from(line));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cip.isbns.sort();
|
||||||
|
cip.isbns.dedup();
|
||||||
|
if cip.is_empty() { None } else { Some(cip) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
//! The copyright page: who published it, where, when, and under what ISBN.
|
||||||
|
//!
|
||||||
|
//! Everything on this page is formula. The same dozen sentences appear in
|
||||||
|
//! every book printed in English, which is what makes them worth matching
|
||||||
|
//! exactly rather than interpreting.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// What the copyright page stated.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct Imprint {
|
||||||
|
/// The press.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Where it publishes from, where the page named one place.
|
||||||
|
pub place: Option<String>,
|
||||||
|
/// The year of this edition, preferring first publication.
|
||||||
|
pub year: Option<u16>,
|
||||||
|
/// Every checksum-valid ISBN printed on the page, in the order printed.
|
||||||
|
pub isbns: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PUBLISHED_BY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?im)^\s*(?:first\s+)?published(?:\s+(?:in\s+)?(?:1[5-9]|20)\d{2})?\s+by\s+(?P<who>[^,.\n]{3,60})")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static PRESS_LINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?im)^\s*(?P<who>[A-Z][^\n]{2,48}?(?:University Press|Press|Publishers|Publishing|Books|Verlag))\s*[,.]?\s*$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A sentence that merely mentions a press is not the press.
|
||||||
|
///
|
||||||
|
/// `Oxford is a registered trade mark of Oxford University Press` ends in a
|
||||||
|
/// publisher's name and is a legal notice.
|
||||||
|
static NOT_AN_IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)( is | are | was |registered|trade mark|trademark|imprint of|division of|©|\(c\)|@|;|\d{4})")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static FIRST_PUBLISHED: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)first published[^.\n]{0,40}?((?:1[5-9]|20)\d{2})").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static COPYRIGHT_YEAR: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)(?:©|\(c\)|copyright)\s*(?:by\s+)?((?:1[5-9]|20)\d{2})")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static ISBN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)isbn(?:[-\s]?1[03])?[:\s]*([0-9][0-9\s\-–—]{8,24}[0-9Xx])")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `Oxford : Oxford University Press, 2007` — the labelled CIP's description,
|
||||||
|
/// which states all three at once.
|
||||||
|
static DESCRIPTION: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?im)^\s*Description\s*:\s*(?P<place>[^:|\n]{2,40}?)\s*:\s*(?P<who>[^,|\n]{3,60}?)\s*,\s*(?P<year>(?:1[5-9]|20)\d{2})")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
fn tidy(s: &str) -> String {
|
||||||
|
s.trim()
|
||||||
|
.trim_end_matches([',', '.', ';'])
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every checksum-valid ISBN announced in a piece of text.
|
||||||
|
pub fn isbns(text: &str) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for caps in ISBN.captures_iter(text) {
|
||||||
|
let digits = bibclean::isbn::digits(&caps[1]);
|
||||||
|
if matches!(digits.len(), 10 | 13)
|
||||||
|
&& bibclean::isbn::valid(&digits)
|
||||||
|
&& !out.contains(&digits)
|
||||||
|
{
|
||||||
|
out.push(digits);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the copyright page.
|
||||||
|
pub fn read(text: &str) -> Imprint {
|
||||||
|
let mut out = Imprint {
|
||||||
|
isbns: isbns(text),
|
||||||
|
..Imprint::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(caps) = DESCRIPTION.captures(text) {
|
||||||
|
out.place = Some(tidy(&caps["place"]));
|
||||||
|
out.publisher = Some(tidy(&caps["who"]));
|
||||||
|
out.year = caps["year"].parse().ok();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.publisher = PUBLISHED_BY
|
||||||
|
.captures_iter(text)
|
||||||
|
.chain(PRESS_LINE.captures_iter(text))
|
||||||
|
.map(|caps| tidy(&caps["who"]))
|
||||||
|
.find(|who| !NOT_AN_IMPRINT.is_match(who) && who.split_whitespace().count() <= 8);
|
||||||
|
out.year = FIRST_PUBLISHED
|
||||||
|
.captures(text)
|
||||||
|
.or_else(|| COPYRIGHT_YEAR.captures(text))
|
||||||
|
.and_then(|caps| caps[1].parse().ok());
|
||||||
|
out.place = text
|
||||||
|
.lines()
|
||||||
|
.map(str::trim)
|
||||||
|
.find(|line| crate::title::PLACE.is_match(line) && line.split_whitespace().count() <= 4)
|
||||||
|
.and_then(|line| {
|
||||||
|
crate::title::PLACE
|
||||||
|
.captures(line)
|
||||||
|
.map(|caps| tidy(&caps["city"]))
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
//! The record a book prints in its own front matter.
|
||||||
|
//!
|
||||||
|
//! A book states its own title twice before the text starts, and states it
|
||||||
|
//! properly: on the title page, set by the publisher, and again in the
|
||||||
|
//! cataloguing block on the copyright page, where a librarian has already
|
||||||
|
//! decided what the title is, who the author is, and what the book is about.
|
||||||
|
//! A file's embedded metadata may say `Untitled` and a filename may say
|
||||||
|
//! `Oxford`, but the printed page says *The First World War: A Very Short
|
||||||
|
//! Introduction*, by Michael Howard.
|
||||||
|
//!
|
||||||
|
//! So this reads the pages, not the file. What comes back is what the book
|
||||||
|
//! printed, with the page it was printed on named for every field, and
|
||||||
|
//! nothing at all where the book printed nothing — a scan that starts at the
|
||||||
|
//! preface has no title page, and the honest record of that is an empty one.
|
||||||
|
//!
|
||||||
|
//! Two traps are worth naming, because both of them are how a reader that
|
||||||
|
//! does not know better ends up cataloguing the wrong book:
|
||||||
|
//!
|
||||||
|
//! - **The series advertisement.** A paperback's front matter lists two
|
||||||
|
//! hundred other books in the same series, in the same shape a title page
|
||||||
|
//! uses: a work in capitals, a person beside it. A title taken off that
|
||||||
|
//! page belongs to somebody else. Those pages are found and refused,
|
||||||
|
//! including the ones that continue the list without repeating its heading.
|
||||||
|
//! - **The uniform title.** A translation's cataloguing block prints the
|
||||||
|
//! original title in brackets above the real one. It is a heading, not the
|
||||||
|
//! title of the book in your hand.
|
||||||
|
//!
|
||||||
|
//! What this does not do is decide whether what the book printed is any
|
||||||
|
//! good. `THE HUMAN CONDITION` is reported in the capitals it was set in;
|
||||||
|
//! straightening that is [`bibclean`]'s job. Nor does it look anything up:
|
||||||
|
//! an ISBN read off the copyright page is a key, and [`bibfetch`] is what
|
||||||
|
//! turns a key into a catalogue record.
|
||||||
|
//!
|
||||||
|
//! [`bibclean`]: https://git.pumpin.online/funkpower/catena
|
||||||
|
//! [`bibfetch`]: https://git.pumpin.online/funkpower/catena
|
||||||
|
|
||||||
|
pub mod cip;
|
||||||
|
pub mod imprint;
|
||||||
|
pub mod page;
|
||||||
|
pub mod prompt;
|
||||||
|
pub mod shelf;
|
||||||
|
pub mod title;
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// How many pages in from the front to read.
|
||||||
|
///
|
||||||
|
/// Front matter that has not finished by here is not front matter. Reading
|
||||||
|
/// further costs nothing but finds chapter headings, which look enough like
|
||||||
|
/// titles to be believed.
|
||||||
|
pub const PAGES: usize = 16;
|
||||||
|
|
||||||
|
/// Which page a field was read off.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Source {
|
||||||
|
/// The cataloguing-in-publication block: a catalogue record, printed.
|
||||||
|
Cip,
|
||||||
|
/// The title page, as the publisher set it.
|
||||||
|
TitlePage,
|
||||||
|
/// The copyright page, outside the cataloguing block.
|
||||||
|
CopyrightPage,
|
||||||
|
/// A model, answering about the pages this could not read. Never
|
||||||
|
/// written by a read: only [`prompt::merge`] puts it on a record.
|
||||||
|
Model,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Source {
|
||||||
|
/// What it is called.
|
||||||
|
pub fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Source::Cip => "cip",
|
||||||
|
Source::TitlePage => "title_page",
|
||||||
|
Source::CopyrightPage => "copyright_page",
|
||||||
|
Source::Model => "model",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What one book printed about itself.
|
||||||
|
///
|
||||||
|
/// Every field is written on every record, empty where the book said
|
||||||
|
/// nothing, and every field that was read names the page it came from in
|
||||||
|
/// `read_from`. The fields a catalogue needs and this could not find are
|
||||||
|
/// listed in `unsettled`, so an absence is legible rather than inferred from
|
||||||
|
/// a missing key.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct Printed {
|
||||||
|
/// Record version.
|
||||||
|
#[serde(default = "version")]
|
||||||
|
pub v: u32,
|
||||||
|
/// The file this was read from, where it was read from one.
|
||||||
|
pub path: Option<String>,
|
||||||
|
/// The title, without its subtitle.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// The subtitle, where the page set one.
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
/// Whoever the book says wrote it, in reading order.
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
/// Whoever the book says edited it.
|
||||||
|
pub editors: Vec<String>,
|
||||||
|
/// Checksum-valid ISBNs printed in the front matter, as printed.
|
||||||
|
pub isbns: Vec<String>,
|
||||||
|
/// The press.
|
||||||
|
pub publisher: Option<String>,
|
||||||
|
/// Where it publishes from.
|
||||||
|
pub place: Option<String>,
|
||||||
|
/// The year of publication, preferring first publication.
|
||||||
|
pub year: Option<u16>,
|
||||||
|
/// The subject headings the cataloguing block filed it under, as printed.
|
||||||
|
pub subjects: Vec<String>,
|
||||||
|
/// Those headings filed onto a caller's own shelf, when one was given.
|
||||||
|
///
|
||||||
|
/// Empty without `--shelf`, and empty too when the book's subjects match
|
||||||
|
/// nothing on it — which is an answer, and the reason `unshelved` exists.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub shelved: Vec<String>,
|
||||||
|
/// Printed headings that matched nothing on the shelf, kept verbatim so a
|
||||||
|
/// subject is never silently dropped.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub unshelved: Vec<String>,
|
||||||
|
/// Which page each field was read off.
|
||||||
|
pub read_from: BTreeMap<String, Source>,
|
||||||
|
/// The fields a catalogue wants that this book did not print.
|
||||||
|
pub unsettled: Vec<String>,
|
||||||
|
/// How many pages were read.
|
||||||
|
pub pages_read: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn version() -> u32 {
|
||||||
|
corpus_core::V
|
||||||
|
}
|
||||||
|
|
||||||
|
impl corpus_core::Record for Printed {
|
||||||
|
fn version(&self) -> u32 {
|
||||||
|
self.v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Printed {
|
||||||
|
/// Did the book print anything at all?
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.title.is_none()
|
||||||
|
&& self.authors.is_empty()
|
||||||
|
&& self.isbns.is_empty()
|
||||||
|
&& self.subjects.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note(&mut self, field: &str, source: Source) {
|
||||||
|
self.read_from.insert(field.to_string(), source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which page holds the title, given where the copyright page is.
|
||||||
|
///
|
||||||
|
/// The title page is the last page before the copyright page that is a page
|
||||||
|
/// of set lines rather than a list or a contents — publishers have put it
|
||||||
|
/// there for two centuries. Where there is no copyright page to count back
|
||||||
|
/// from, the first such page before the text starts is all there is to go on.
|
||||||
|
fn title_page(pages: &[page::Page], copyright: Option<usize>) -> Option<usize> {
|
||||||
|
let usable = |i: usize| pages.get(i).is_some_and(|p| p.kind == page::Kind::Title);
|
||||||
|
if let Some(c) = copyright {
|
||||||
|
if let Some(found) = (0..c).rev().find(|i| usable(*i)) {
|
||||||
|
return Some(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
.iter()
|
||||||
|
.position(|p| p.kind == page::Kind::Text)
|
||||||
|
.map_or_else(
|
||||||
|
|| (0..pages.len()).find(|i| usable(*i)),
|
||||||
|
|end| (0..end).find(|i| usable(*i)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The longest printing of the same title anywhere in the front matter.
|
||||||
|
///
|
||||||
|
/// A title page breaks its title across lines and sets it in capitals; the
|
||||||
|
/// half title two leaves earlier prints the whole of it on one line, with
|
||||||
|
/// its subtitle, in the case the publisher meant. They are the same title,
|
||||||
|
/// and the unbroken one is the one to keep.
|
||||||
|
fn longest_printing<'a>(pages: &'a [page::Page], title: &str) -> Option<&'a str> {
|
||||||
|
let wanted = squash(title);
|
||||||
|
if wanted.len() < 4 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
pages
|
||||||
|
.iter()
|
||||||
|
.filter(|p| matches!(p.kind, page::Kind::Title | page::Kind::Copyright))
|
||||||
|
.flat_map(|p| p.lines.iter())
|
||||||
|
.filter(|line| {
|
||||||
|
let candidate = squash(line);
|
||||||
|
candidate.starts_with(&wanted) && candidate.len() > wanted.len()
|
||||||
|
})
|
||||||
|
.min_by_key(|line| line.len())
|
||||||
|
.map(String::as_str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A string reduced to the letters and digits in it, for comparing two
|
||||||
|
/// printings of one title across a change of case and punctuation.
|
||||||
|
fn squash(s: &str) -> String {
|
||||||
|
s.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric())
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a book's front matter, and file its subjects onto a shelf.
|
||||||
|
///
|
||||||
|
/// `budget` is how many pages in to read; [`PAGES`] is the sensible default.
|
||||||
|
/// Passing a [`Shelf`](shelf::Shelf) fills `shelved` and `unshelved`; without
|
||||||
|
/// one, `subjects` is still read and both stay empty.
|
||||||
|
pub fn read_onto(text: &str, budget: usize, shelf: Option<&shelf::Shelf>) -> Printed {
|
||||||
|
let mut out = read(text, budget);
|
||||||
|
if let Some(shelf) = shelf.filter(|s| !s.is_empty()) {
|
||||||
|
let filed = shelf.file(&out.subjects);
|
||||||
|
if !filed.on.is_empty() {
|
||||||
|
out.note("shelved", Source::Cip);
|
||||||
|
}
|
||||||
|
out.shelved = filed.on;
|
||||||
|
out.unshelved = filed.unfiled;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a book's front matter.
|
||||||
|
///
|
||||||
|
/// `budget` is how many pages in to read; [`PAGES`] is the sensible default.
|
||||||
|
pub fn read(text: &str, budget: usize) -> Printed {
|
||||||
|
let pages = page::split(text, budget);
|
||||||
|
let mut out = Printed {
|
||||||
|
v: version(),
|
||||||
|
path: None,
|
||||||
|
title: None,
|
||||||
|
subtitle: None,
|
||||||
|
authors: Vec::new(),
|
||||||
|
editors: Vec::new(),
|
||||||
|
isbns: Vec::new(),
|
||||||
|
publisher: None,
|
||||||
|
place: None,
|
||||||
|
year: None,
|
||||||
|
subjects: Vec::new(),
|
||||||
|
shelved: Vec::new(),
|
||||||
|
unshelved: Vec::new(),
|
||||||
|
read_from: BTreeMap::new(),
|
||||||
|
unsettled: Vec::new(),
|
||||||
|
pages_read: pages.len(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let front: String = pages
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.text.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
let copyright = pages.iter().position(|p| p.kind == page::Kind::Copyright);
|
||||||
|
|
||||||
|
// The cataloguing block is a record somebody else already made, so it
|
||||||
|
// outranks the title page wherever the two disagree.
|
||||||
|
if let Some(found) = cip::read(&front) {
|
||||||
|
if let Some(t) = found.title {
|
||||||
|
out.title = Some(t);
|
||||||
|
out.note("title", Source::Cip);
|
||||||
|
}
|
||||||
|
if let Some(s) = found.subtitle {
|
||||||
|
out.subtitle = Some(s);
|
||||||
|
out.note("subtitle", Source::Cip);
|
||||||
|
}
|
||||||
|
if !found.authors.is_empty() {
|
||||||
|
out.authors = found.authors;
|
||||||
|
out.note("authors", Source::Cip);
|
||||||
|
}
|
||||||
|
if !found.editors.is_empty() {
|
||||||
|
out.editors = found.editors;
|
||||||
|
out.note("editors", Source::Cip);
|
||||||
|
}
|
||||||
|
if !found.subjects.is_empty() {
|
||||||
|
out.subjects = found.subjects;
|
||||||
|
out.note("subjects", Source::Cip);
|
||||||
|
}
|
||||||
|
out.isbns = found.isbns;
|
||||||
|
if !out.isbns.is_empty() {
|
||||||
|
out.note("isbns", Source::Cip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(page) = title_page(&pages, copyright).and_then(|i| title::read(&pages[i].lines)) {
|
||||||
|
if out.title.is_none() {
|
||||||
|
out.title = match page
|
||||||
|
.title
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|t| longest_printing(&pages, t))
|
||||||
|
{
|
||||||
|
Some(longer) => {
|
||||||
|
let (title, subtitle) = longer
|
||||||
|
.split_once(" : ")
|
||||||
|
.or_else(|| longer.split_once(": "))
|
||||||
|
.map_or((longer.to_string(), None), |(head, tail)| {
|
||||||
|
(head.trim().to_string(), Some(tail.trim().to_string()))
|
||||||
|
});
|
||||||
|
out.subtitle = subtitle;
|
||||||
|
Some(title)
|
||||||
|
}
|
||||||
|
None => page.title,
|
||||||
|
};
|
||||||
|
if out.title.is_some() {
|
||||||
|
out.note("title", Source::TitlePage);
|
||||||
|
}
|
||||||
|
if out.subtitle.is_none() && page.subtitle.is_some() {
|
||||||
|
out.subtitle = page.subtitle;
|
||||||
|
out.note("subtitle", Source::TitlePage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if out.authors.is_empty() && !page.authors.is_empty() {
|
||||||
|
out.authors = page.authors;
|
||||||
|
out.note("authors", Source::TitlePage);
|
||||||
|
}
|
||||||
|
if out.editors.is_empty() && !page.editors.is_empty() {
|
||||||
|
out.editors = page.editors;
|
||||||
|
out.note("editors", Source::TitlePage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let imprint_text = copyright.map_or(front.as_str(), |i| pages[i].text.as_str());
|
||||||
|
let stated = imprint::read(imprint_text);
|
||||||
|
for isbn in stated.isbns {
|
||||||
|
if !out.isbns.contains(&isbn) {
|
||||||
|
out.isbns.push(isbn);
|
||||||
|
out.read_from
|
||||||
|
.entry("isbns".into())
|
||||||
|
.or_insert(Source::CopyrightPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(publisher) = stated.publisher {
|
||||||
|
out.publisher = Some(publisher);
|
||||||
|
out.note("publisher", Source::CopyrightPage);
|
||||||
|
}
|
||||||
|
if let Some(place) = stated.place {
|
||||||
|
out.place = Some(place);
|
||||||
|
out.note("place", Source::CopyrightPage);
|
||||||
|
}
|
||||||
|
if let Some(year) = stated.year {
|
||||||
|
out.year = Some(year);
|
||||||
|
out.note("year", Source::CopyrightPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (field, missing) in [
|
||||||
|
("title", out.title.is_none()),
|
||||||
|
("authors", out.authors.is_empty() && out.editors.is_empty()),
|
||||||
|
("isbns", out.isbns.is_empty()),
|
||||||
|
("publisher", out.publisher.is_none()),
|
||||||
|
("year", out.year.is_none()),
|
||||||
|
("subjects", out.subjects.is_empty()),
|
||||||
|
] {
|
||||||
|
if missing {
|
||||||
|
out.unsettled.push(field.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
//! The record a book prints in its own front matter.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
use clap::Parser;
|
||||||
|
use corpus_core::exit;
|
||||||
|
|
||||||
|
/// Read what a book prints about itself in its opening pages.
|
||||||
|
///
|
||||||
|
/// The input is the book as markdown — the whole of it, or as much of the
|
||||||
|
/// front as you care to pass. The title page and the cataloguing block on
|
||||||
|
/// the copyright page are read, and what they state comes back as one
|
||||||
|
/// record, with the page each field was read off named in `read_from`.
|
||||||
|
///
|
||||||
|
/// Nothing is inferred. A scan that starts at chapter one prints no title
|
||||||
|
/// page, and what comes back then is a record with nothing in it and every
|
||||||
|
/// field named in `unsettled` — which is the answer, and a usable one.
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
version,
|
||||||
|
about,
|
||||||
|
after_help = "\
|
||||||
|
Exit codes: 0 the book printed something about itself, 1 it printed nothing
|
||||||
|
this could read, 65 the input was not readable as text, 74 the file could not
|
||||||
|
be read.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
colophon book.md | jq -r '[.title, (.authors|join(\", \"))] | @tsv'
|
||||||
|
colophon *.md | jq -r 'select(.unsettled | index(\"title\")) | .path'
|
||||||
|
colophon book.md | jq -r '.isbns[]' | idnorm | bibfetch"
|
||||||
|
)]
|
||||||
|
struct Args {
|
||||||
|
/// The books to read. Standard input when none are given.
|
||||||
|
paths: Vec<PathBuf>,
|
||||||
|
|
||||||
|
/// Say nothing; the exit code is the answer.
|
||||||
|
#[arg(long, short)]
|
||||||
|
quiet: bool,
|
||||||
|
|
||||||
|
/// Write the question to put to a model about the fields these pages
|
||||||
|
/// did not settle, instead of the record. Nothing is sent: it goes to
|
||||||
|
/// stdout. One book at a time, because the pages differ.
|
||||||
|
#[arg(long)]
|
||||||
|
prompt: bool,
|
||||||
|
|
||||||
|
/// Fill in what the pages did not settle from a model's reply, written
|
||||||
|
/// earlier by `--prompt`, and write the completed record.
|
||||||
|
#[arg(long, value_name = "FILE")]
|
||||||
|
merge: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// How many pages in from the front to read.
|
||||||
|
#[arg(long, value_name = "COUNT", default_value_t = colophon::PAGES)]
|
||||||
|
pages: usize,
|
||||||
|
|
||||||
|
/// A closed list of headings to file the book's printed subjects under.
|
||||||
|
///
|
||||||
|
/// One heading per line, optionally `Heading = term, term` to give the
|
||||||
|
/// words that should match it; a heading with no terms matches its own
|
||||||
|
/// name. A cataloguing block states subjects in Library of Congress form,
|
||||||
|
/// which is no use as a shelf, so this says what the shelf is. What
|
||||||
|
/// matches nothing comes back under `unshelved` rather than being dropped.
|
||||||
|
#[arg(long, value_name = "FILE")]
|
||||||
|
shelf: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
let args: Args = match catena_cli::parse() {
|
||||||
|
Ok(args) => args,
|
||||||
|
Err(code) => return code,
|
||||||
|
};
|
||||||
|
if args.pages == 0 {
|
||||||
|
eprintln!("colophon: --pages 0 would read nothing");
|
||||||
|
return ExitCode::from(exit::USAGE);
|
||||||
|
}
|
||||||
|
if args.prompt && args.merge.is_some() {
|
||||||
|
eprintln!("colophon: --prompt composes a question and --merge answers one");
|
||||||
|
return ExitCode::from(exit::USAGE);
|
||||||
|
}
|
||||||
|
if (args.prompt || args.merge.is_some()) && args.paths.len() > 1 {
|
||||||
|
eprintln!("colophon: one book at a time for --prompt and --merge");
|
||||||
|
return ExitCode::from(exit::USAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the shelf before any book, so a bad path fails at once rather than
|
||||||
|
// after the whole library has been read with no shelf applied.
|
||||||
|
let shelf = match args.shelf.as_deref() {
|
||||||
|
None => None,
|
||||||
|
Some(path) => match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => {
|
||||||
|
let s = colophon::shelf::Shelf::parse(&text);
|
||||||
|
if s.is_empty() {
|
||||||
|
eprintln!("colophon: {} lists no headings", path.display());
|
||||||
|
return ExitCode::from(exit::USAGE);
|
||||||
|
}
|
||||||
|
Some(s)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("colophon: {}: {e}", path.display());
|
||||||
|
return ExitCode::from(exit::USAGE);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let inputs: Vec<Option<PathBuf>> = if args.paths.is_empty() {
|
||||||
|
vec![None]
|
||||||
|
} else {
|
||||||
|
args.paths.iter().cloned().map(Some).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut records = Vec::with_capacity(inputs.len());
|
||||||
|
let mut last_text = String::new();
|
||||||
|
for path in inputs {
|
||||||
|
let text = match catena_cli::read_text(path.as_deref()) {
|
||||||
|
Ok(text) => text,
|
||||||
|
Err((code, why)) => {
|
||||||
|
eprintln!("colophon: {why}");
|
||||||
|
return ExitCode::from(code);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut record = colophon::read_onto(&text, args.pages, shelf.as_ref());
|
||||||
|
record.path = path.as_deref().map(|p| p.display().to_string());
|
||||||
|
records.push(record);
|
||||||
|
last_text = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if args.prompt {
|
||||||
|
let record = &records[0];
|
||||||
|
return match colophon::prompt::compose(record, &last_text, args.pages) {
|
||||||
|
// Nothing left to ask is not a failure, and a question nobody
|
||||||
|
// needs would waste a model's time.
|
||||||
|
None => ExitCode::from(exit::NO),
|
||||||
|
Some(question) => catena_cli::text("colophon", &question),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Some(path) = args.merge.as_deref() {
|
||||||
|
let reply = match std::fs::read_to_string(path) {
|
||||||
|
Ok(reply) => reply,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("colophon: {}: {e}", path.display());
|
||||||
|
return ExitCode::from(exit::IO);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match colophon::prompt::merge(&mut records[0], &reply) {
|
||||||
|
Err(why) => {
|
||||||
|
eprintln!("colophon: {why}");
|
||||||
|
return ExitCode::from(exit::DATA);
|
||||||
|
}
|
||||||
|
Ok(0) => eprintln!("colophon: the reply filled nothing in"),
|
||||||
|
Ok(filled) => eprintln!("colophon: {filled} field(s) filled in from the reply"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let anything = records.iter().any(|r| !r.is_empty());
|
||||||
|
if args.quiet {
|
||||||
|
return ExitCode::from(if anything { exit::OK } else { exit::NO });
|
||||||
|
}
|
||||||
|
if !anything {
|
||||||
|
eprintln!("colophon: nothing in these pages says what the book is");
|
||||||
|
for record in &records {
|
||||||
|
let _ = catena_cli::stream("colophon", std::slice::from_ref(record));
|
||||||
|
}
|
||||||
|
return ExitCode::from(exit::NO);
|
||||||
|
}
|
||||||
|
catena_cli::stream("colophon", &records)
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
//! The opening of a book, cut into pages and each page named for what it is.
|
||||||
|
//!
|
||||||
|
//! Front matter is not prose and does not read like it. A title page is four
|
||||||
|
//! lines and no sentence; a copyright page is a dense block of formulae that
|
||||||
|
//! recur from publisher to publisher; a series advertisement is a list of
|
||||||
|
//! other people's books. Telling them apart is most of the work, because the
|
||||||
|
//! title that matters is on one of them and the titles that will mislead you
|
||||||
|
//! are on another.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
/// What a page of front matter is.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Kind {
|
||||||
|
/// Nothing legible on it.
|
||||||
|
Blank,
|
||||||
|
/// Other books, advertised. The trap: it names two hundred works that
|
||||||
|
/// are not this one, in the same shapes a title page uses.
|
||||||
|
SeriesList,
|
||||||
|
/// Publication facts: the imprint, the copyright, the ISBN, the CIP block.
|
||||||
|
Copyright,
|
||||||
|
/// The book's own table of contents.
|
||||||
|
Contents,
|
||||||
|
/// Few lines, no sentences — a title page, a half title, or a dedication.
|
||||||
|
Title,
|
||||||
|
/// Running text. The front matter is over.
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One page, cleaned of what the converter added.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Page {
|
||||||
|
/// Page number as the marker gave it, or position when there was none.
|
||||||
|
pub number: u32,
|
||||||
|
/// Legible lines, in order, with markup and typesetting slugs removed.
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
/// The page's text as it arrived, for the block readers.
|
||||||
|
pub text: String,
|
||||||
|
/// What the page is.
|
||||||
|
pub kind: Kind,
|
||||||
|
}
|
||||||
|
|
||||||
|
static PAGE_MARKER: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?im)^\s*<!--\s*page\s+(\d+)\s*-->\s*$").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static IMAGE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"!\[[^\]]*\]\([^)]*\)").expect("a valid pattern"));
|
||||||
|
static HEADING: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^#{1,6}\s+").expect("a valid pattern"));
|
||||||
|
/// `DESA01 19/9/06 6:02 PM Page i` — the typesetter's slug, printed by
|
||||||
|
/// accident and carried by every page of some books.
|
||||||
|
static SLUG: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^[a-z]{2,10}[-_ ]?\d{1,6}\s+\d{1,2}/\d{1,2}/\d{2,4}.*page\s+[ivxlcdm\d]+\s*$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A converted EPUB's navigation: `[Begin Reading](dedication.xhtml#ded)`,
|
||||||
|
/// and the page links some converters leave behind.
|
||||||
|
static NAVIGATION: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(<|\[[^\]]*\]\([^)]*\)|https?://|www\.|previous page|next page|page_\w+)")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static FOLIO: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^[\d\s.\-–—ivxlcdmIVXLCDM|]+$").expect("a valid pattern"));
|
||||||
|
static BLANK_NOTE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(this page (has been )?intentionally left blank|blank page)\.?$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static LIST_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)(available now|available soon|also by|also in this series|also available|\
|
||||||
|
other titles|other books|forthcoming|in the same series|praise for|advance praise|\
|
||||||
|
very short introductions|series editors?|titles in this series)",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static COPYRIGHT_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)(all rights reserved|first published|first edition|copyright\s*[©®(]?|[©®]\s*\d{4}|\(c\)\s*\d{4}|\
|
||||||
|
library of congress|british library|catalog(u)?ing[- ]in[- ]publication|\
|
||||||
|
printed (in|and bound)|isbn|no part of this (book|publication)|\
|
||||||
|
typeset (in|by)|a cip (record|catalogue))",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static CONTENTS_CUE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(table of )?contents$|\.{4,}\s*\d+\s*$").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A line of a series list: a work, then whoever wrote it, on one line.
|
||||||
|
static LISTED_WORK: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[A-Z][A-Za-z'’\-]*(?:[ ,][A-Z][A-Za-z'’\-]*){0,7}\s+[A-Z][a-z'’\-]+\s+[A-Z][A-Za-z'’\-]+\.?$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A person's name alone on a line.
|
||||||
|
pub static PERSON: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^(?:[A-Z][A-Za-z'’\-\.]{0,20}\s+){1,3}[A-Z][A-Za-z'’\-]{1,25}\.?$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Letters spread across a line by a PDF's glyph positions, put back together.
|
||||||
|
///
|
||||||
|
/// `A N E W T R A N SL AT ION` is one word to a reader and eleven tokens to a
|
||||||
|
/// parser, and the line it ruins is usually the title.
|
||||||
|
pub fn despread(line: &str) -> String {
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.len() < 4 {
|
||||||
|
return line.to_string();
|
||||||
|
}
|
||||||
|
let singles = tokens.iter().filter(|t| t.chars().count() == 1).count();
|
||||||
|
if singles * 2 < tokens.len() {
|
||||||
|
return line.to_string();
|
||||||
|
}
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
let mut run = String::new();
|
||||||
|
for token in tokens {
|
||||||
|
if token.chars().count() <= 3 && token.chars().all(char::is_alphabetic) {
|
||||||
|
run.push_str(token);
|
||||||
|
} else {
|
||||||
|
if !run.is_empty() {
|
||||||
|
out.push(std::mem::take(&mut run));
|
||||||
|
}
|
||||||
|
out.push(token.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !run.is_empty() {
|
||||||
|
out.push(run);
|
||||||
|
}
|
||||||
|
out.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the line so damaged that reading it would invent something?
|
||||||
|
///
|
||||||
|
/// A font whose glyphs map into the private use area comes out of extraction
|
||||||
|
/// as a line of unassigned code points. It looks like text to a length check
|
||||||
|
/// and is not text.
|
||||||
|
fn illegible(line: &str) -> bool {
|
||||||
|
let total = line.chars().filter(|c| !c.is_whitespace()).count();
|
||||||
|
if total == 0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let bad = line
|
||||||
|
.chars()
|
||||||
|
.filter(|c| {
|
||||||
|
let n = *c as u32;
|
||||||
|
(0xE000..=0xF8FF).contains(&n) || *c == '\u{FFFD}'
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
bad * 5 > total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_line(raw: &str) -> Option<String> {
|
||||||
|
let without_images = IMAGE.replace_all(raw, " ");
|
||||||
|
let stripped = HEADING.replace(without_images.trim(), "");
|
||||||
|
let line = despread(
|
||||||
|
stripped
|
||||||
|
.trim()
|
||||||
|
.trim_matches(|c| c == '*' || c == '_' || c == '|')
|
||||||
|
.trim(),
|
||||||
|
);
|
||||||
|
if line.is_empty()
|
||||||
|
|| illegible(&line)
|
||||||
|
|| SLUG.is_match(&line)
|
||||||
|
|| NAVIGATION.is_match(&line)
|
||||||
|
|| FOLIO.is_match(&line)
|
||||||
|
|| BLANK_NOTE.is_match(&line)
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn looks_like_prose(line: &str) -> bool {
|
||||||
|
let words = line.split_whitespace().count();
|
||||||
|
words > 14 || (words > 8 && line.trim_end().ends_with(['.', ';', ',']))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn classify(lines: &[String], text: &str) -> Kind {
|
||||||
|
if lines.is_empty() {
|
||||||
|
return Kind::Blank;
|
||||||
|
}
|
||||||
|
if COPYRIGHT_CUE.find_iter(text).count() >= 2 {
|
||||||
|
return Kind::Copyright;
|
||||||
|
}
|
||||||
|
if LIST_CUE.is_match(text) && lines.len() >= 5 {
|
||||||
|
return Kind::SeriesList;
|
||||||
|
}
|
||||||
|
let listed = lines.iter().filter(|l| LISTED_WORK.is_match(l)).count();
|
||||||
|
if lines.len() >= 6 && listed * 5 >= lines.len() * 2 {
|
||||||
|
return Kind::SeriesList;
|
||||||
|
}
|
||||||
|
if lines.iter().take(3).any(|l| CONTENTS_CUE.is_match(l))
|
||||||
|
|| lines.iter().filter(|l| CONTENTS_CUE.is_match(l)).count() >= 4
|
||||||
|
{
|
||||||
|
return Kind::Contents;
|
||||||
|
}
|
||||||
|
if lines.len() <= 12 && !lines.iter().any(|l| looks_like_prose(l)) {
|
||||||
|
return Kind::Title;
|
||||||
|
}
|
||||||
|
Kind::Text
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cut the opening into pages and name each one.
|
||||||
|
///
|
||||||
|
/// Page markers are used where the converter left them. Where it did not —
|
||||||
|
/// an EPUB has no pages to mark — the blank lines are the only boundary
|
||||||
|
/// there is, and blocks stand in for pages. That is weaker, which is why a
|
||||||
|
/// book converted from EPUB usually comes back with nothing but its ISBN:
|
||||||
|
/// the metadata is in its OPF, and [`bookmeta`] reads that.
|
||||||
|
///
|
||||||
|
/// [`bookmeta`]: https://git.pumpin.online/funkpower/catena
|
||||||
|
pub fn split(text: &str, budget: usize) -> Vec<Page> {
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
if PAGE_MARKER.is_match(text) {
|
||||||
|
let mut last_end = 0;
|
||||||
|
let mut number = 0u32;
|
||||||
|
for m in PAGE_MARKER.captures_iter(text) {
|
||||||
|
let whole = m.get(0).expect("group zero is always there");
|
||||||
|
if number > 0 || whole.start() > 0 {
|
||||||
|
push(&mut pages, number, &text[last_end..whole.start()]);
|
||||||
|
}
|
||||||
|
number = m[1].parse().unwrap_or(number + 1);
|
||||||
|
last_end = whole.end();
|
||||||
|
if pages.len() > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
push(&mut pages, number, &text[last_end..]);
|
||||||
|
} else {
|
||||||
|
for (i, block) in text.split("\n\n").enumerate() {
|
||||||
|
push(&mut pages, i as u32 + 1, block);
|
||||||
|
if pages.len() > budget {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages.truncate(budget);
|
||||||
|
continuations(&mut pages);
|
||||||
|
pages
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push(pages: &mut Vec<Page>, number: u32, text: &str) {
|
||||||
|
let lines: Vec<String> = text.lines().filter_map(clean_line).collect();
|
||||||
|
let kind = classify(&lines, text);
|
||||||
|
pages.push(Page {
|
||||||
|
number,
|
||||||
|
lines,
|
||||||
|
text: text.to_string(),
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A series list runs over several pages and only the first one announces
|
||||||
|
/// itself. The pages after it are the same list and have to be refused too,
|
||||||
|
/// or the title taken off one of them is another book's.
|
||||||
|
fn continuations(pages: &mut [Page]) {
|
||||||
|
for i in 1..pages.len() {
|
||||||
|
if pages[i - 1].kind != Kind::SeriesList {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let page = &pages[i];
|
||||||
|
if page.kind == Kind::Blank || page.lines.len() < 6 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Only a page that is itself a list continues one. Counting bare
|
||||||
|
// names here would take a title page for advertising, because a
|
||||||
|
// title set in capitals and broken across lines is, line by line,
|
||||||
|
// the same shape as a person's name.
|
||||||
|
let listed = page
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter(|l| LISTED_WORK.is_match(l))
|
||||||
|
.count();
|
||||||
|
if page.lines.len() >= 6 && listed * 2 >= page.lines.len() {
|
||||||
|
pages[i].kind = Kind::SeriesList;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! A question to put to a model, for what the pages did not settle.
|
||||||
|
//!
|
||||||
|
//! Nothing here sends anything. The prompt is composed and printed, and what
|
||||||
|
//! happens to it is the caller's business — their model, their machine,
|
||||||
|
//! their GPU. The tool stays what every tool here is: the same output for
|
||||||
|
//! the same input, on every machine, with no network and nothing installed.
|
||||||
|
//!
|
||||||
|
//! What the model is shown is not the front matter as it arrived. The pages
|
||||||
|
//! that advertise other books are taken out first, because handing them to a
|
||||||
|
//! model is how a book ends up catalogued as whichever of two hundred other
|
||||||
|
//! titles the model's eye fell on. It is shown the pages that describe this
|
||||||
|
//! book, told what was already read off them, and asked only for the rest.
|
||||||
|
|
||||||
|
use crate::{Printed, Source, page};
|
||||||
|
|
||||||
|
/// The version of the prompt below.
|
||||||
|
///
|
||||||
|
/// Bumped when the wording changes in a way that could change an answer, so
|
||||||
|
/// a caller keeping results can tell which prompt produced them.
|
||||||
|
pub const PROMPT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// How much of the front matter to show. Enough for a title page, a
|
||||||
|
/// copyright page and a contents; not enough to wander into the text.
|
||||||
|
const SHOWN: usize = 6000;
|
||||||
|
|
||||||
|
/// The fields a model may be asked for.
|
||||||
|
const ASKABLE: &[&str] = &["title", "authors", "publisher", "year", "subjects"];
|
||||||
|
|
||||||
|
fn known(record: &Printed) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
if let Some(title) = &record.title {
|
||||||
|
out.push_str(&format!("title already read: {title}\n"));
|
||||||
|
}
|
||||||
|
if !record.authors.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"authors already read: {}\n",
|
||||||
|
record.authors.join("; ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if !record.isbns.is_empty() {
|
||||||
|
out.push_str(&format!(
|
||||||
|
"isbn printed in the book: {}\n",
|
||||||
|
record.isbns.join(", ")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(publisher) = &record.publisher {
|
||||||
|
out.push_str(&format!("publisher already read: {publisher}\n"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The front matter with the advertising taken out.
|
||||||
|
pub fn readable_pages(text: &str, budget: usize) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for page in page::split(text, budget) {
|
||||||
|
if matches!(page.kind, page::Kind::SeriesList | page::Kind::Blank) || page.lines.is_empty()
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push_str(&format!("--- page {}\n", page.number));
|
||||||
|
for line in &page.lines {
|
||||||
|
out.push_str(line);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
if out.len() >= SHOWN {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.truncate(SHOWN);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compose the question, or decline to.
|
||||||
|
///
|
||||||
|
/// `None` when the pages settled everything a model could help with, because
|
||||||
|
/// there is nothing to ask and a call to a model would be waste.
|
||||||
|
pub fn compose(record: &Printed, text: &str, budget: usize) -> Option<String> {
|
||||||
|
let wanted: Vec<&str> = ASKABLE
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|field| record.unsettled.iter().any(|f| f == field))
|
||||||
|
.collect();
|
||||||
|
if wanted.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let pages = readable_pages(text, budget);
|
||||||
|
if pages.trim().is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::from(
|
||||||
|
"Below are the opening pages of one book, as text. Pages advertising \
|
||||||
|
other books have already been removed.\n\n",
|
||||||
|
);
|
||||||
|
out.push_str(&pages);
|
||||||
|
out.push('\n');
|
||||||
|
let already = known(record);
|
||||||
|
if !already.is_empty() {
|
||||||
|
out.push_str("Read off these pages already:\n");
|
||||||
|
out.push_str(&already);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out.push_str(&format!("wanted: {}\n\n", wanted.join(", ")));
|
||||||
|
out.push_str(
|
||||||
|
"Reply with one JSON object and nothing else, using only the keys \
|
||||||
|
listed as wanted. `title` is the book's own title, without its \
|
||||||
|
subtitle. `authors` is a list of the people who wrote it, as the \
|
||||||
|
book writes their names. `publisher` is the press. `year` is a \
|
||||||
|
four-digit number. `subjects` is a list of at most five subject \
|
||||||
|
headings for what the book is about.\n\n\
|
||||||
|
Answer only from the pages above. Use null for anything they do not \
|
||||||
|
settle: a guess is worse than an absence, because nobody downstream \
|
||||||
|
can tell them apart. A title listed beside other titles is another \
|
||||||
|
book's, not this one's.\n",
|
||||||
|
);
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill in what the pages did not settle from a model's reply.
|
||||||
|
///
|
||||||
|
/// Only the fields that were unsettled are filled, and each one is marked as
|
||||||
|
/// having come from a model — a record that cannot say which of its fields
|
||||||
|
/// were read and which were answered is a record nobody can apply a policy
|
||||||
|
/// to. Returns how many fields were filled.
|
||||||
|
pub fn merge(record: &mut Printed, reply: &str) -> Result<usize, String> {
|
||||||
|
let start = reply.find('{').ok_or("the reply holds no JSON object")?;
|
||||||
|
let end = reply.rfind('}').ok_or("the reply holds no JSON object")?;
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&reply[start..=end])
|
||||||
|
.map_err(|e| format!("the reply is not JSON: {e}"))?;
|
||||||
|
let object = parsed.as_object().ok_or("the reply is not a JSON object")?;
|
||||||
|
|
||||||
|
let mut filled = 0;
|
||||||
|
let wanted = |record: &Printed, field: &str| record.unsettled.iter().any(|f| f == field);
|
||||||
|
|
||||||
|
if wanted(record, "title") {
|
||||||
|
if let Some(title) = object.get("title").and_then(|v| v.as_str()) {
|
||||||
|
let title = title.trim();
|
||||||
|
if !title.is_empty() {
|
||||||
|
record.title = Some(title.to_string());
|
||||||
|
record.read_from.insert("title".into(), Source::Model);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wanted(record, "publisher") {
|
||||||
|
if let Some(publisher) = object.get("publisher").and_then(|v| v.as_str()) {
|
||||||
|
let publisher = publisher.trim();
|
||||||
|
if !publisher.is_empty() {
|
||||||
|
record.publisher = Some(publisher.to_string());
|
||||||
|
record.read_from.insert("publisher".into(), Source::Model);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wanted(record, "year") {
|
||||||
|
if let Some(year) = object.get("year").and_then(serde_json::Value::as_u64) {
|
||||||
|
if (1400..=2200).contains(&year) {
|
||||||
|
record.year = Some(year as u16);
|
||||||
|
record.read_from.insert("year".into(), Source::Model);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (field, into) in [("authors", true), ("subjects", false)] {
|
||||||
|
if !wanted(record, field) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(list) = object.get(field).and_then(|v| v.as_array()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let values: Vec<String> = list
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.as_str())
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
if values.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if into {
|
||||||
|
record.authors = values;
|
||||||
|
} else {
|
||||||
|
record.subjects = values;
|
||||||
|
}
|
||||||
|
record.read_from.insert(field.into(), Source::Model);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
record
|
||||||
|
.unsettled
|
||||||
|
.retain(|field| !record.read_from.contains_key(field));
|
||||||
|
Ok(filled)
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
//! Filing a book's printed subject headings onto a shelf someone actually has.
|
||||||
|
//!
|
||||||
|
//! A cataloguing block states subjects in Library of Congress form, which is
|
||||||
|
//! precise, deep, and no use as a shelf. Real blocks from real books read:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! Political science – Early works to 1800
|
||||||
|
//! English literature—Middle English, 1100–1500—History and criticism
|
||||||
|
//! Methodology. 2.Anthropic principle
|
||||||
|
//! Hobbes, Thomas, 1588–1679. Leviathan
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Nobody browses by those. A library is browsed by a short closed list its
|
||||||
|
//! owner chose, and the job here is to get from the first to the second.
|
||||||
|
//!
|
||||||
|
//! The mapping is deliberately dumb: a heading is matched against the terms of
|
||||||
|
//! the shelf, longest first, and the first hit wins. It infers nothing, invents
|
||||||
|
//! nothing, and returns nothing at all rather than guess — a heading that
|
||||||
|
//! matches no term is reported unfiled, with its raw text kept, because a
|
||||||
|
//! subject silently dropped is indistinguishable from a book with no subjects.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
/// A closed list of headings, with the words that file a book under each.
|
||||||
|
///
|
||||||
|
/// Parsed from a plain text file: one heading per line, optionally followed by
|
||||||
|
/// `=` and the comma-separated terms that should match it. A heading with no
|
||||||
|
/// terms matches its own name.
|
||||||
|
///
|
||||||
|
/// ```text
|
||||||
|
/// Byzantine History = byzantium, byzantine, constantinople, eastern roman
|
||||||
|
/// Patristics = church fathers, patristic, patrology
|
||||||
|
/// Ethics
|
||||||
|
/// ```
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct Shelf {
|
||||||
|
/// (term, heading), longest term first so the most specific wins.
|
||||||
|
terms: Vec<(String, String)>,
|
||||||
|
headings: BTreeSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shelf {
|
||||||
|
/// Read a shelf from the list format above. Blank lines and `#` comments
|
||||||
|
/// are skipped.
|
||||||
|
pub fn parse(text: &str) -> Self {
|
||||||
|
let mut terms: Vec<(String, String)> = Vec::new();
|
||||||
|
let mut headings = BTreeSet::new();
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (heading, rest) = match line.split_once('=') {
|
||||||
|
Some((h, r)) => (h.trim(), r.trim()),
|
||||||
|
None => (line, ""),
|
||||||
|
};
|
||||||
|
if heading.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
headings.insert(heading.to_string());
|
||||||
|
terms.push((fold(heading), heading.to_string()));
|
||||||
|
for term in rest.split(',') {
|
||||||
|
let term = fold(term);
|
||||||
|
if !term.is_empty() {
|
||||||
|
terms.push((term, heading.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Longest first: "ancient philosophy" must beat "philosophy", or every
|
||||||
|
// period of thought files under the parent and the shelf does no work.
|
||||||
|
terms.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
|
||||||
|
terms.dedup_by(|a, b| a.0 == b.0);
|
||||||
|
Self { terms, headings }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the shelf lists no headings, in which case it files nothing.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.terms.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a name is one of the shelf's own headings.
|
||||||
|
pub fn has(&self, heading: &str) -> bool {
|
||||||
|
self.headings.contains(heading)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The heading a single raw subject files under, if any.
|
||||||
|
pub fn file_one(&self, raw: &str) -> Option<&str> {
|
||||||
|
let hay = fold(raw);
|
||||||
|
if hay.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.terms
|
||||||
|
.iter()
|
||||||
|
.find(|(term, _)| contains_word(&hay, term))
|
||||||
|
.map(|(_, heading)| heading.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// File a book's raw headings, in the shelf's own order, without repeats.
|
||||||
|
///
|
||||||
|
/// Returns the headings that matched and the raw subjects that did not, so
|
||||||
|
/// a caller can tell "this book is not about anything on the shelf" from
|
||||||
|
/// "this book printed no subjects".
|
||||||
|
pub fn file(&self, raw: &[String]) -> Filed {
|
||||||
|
let mut on = Vec::new();
|
||||||
|
let mut unfiled = Vec::new();
|
||||||
|
for subject in raw {
|
||||||
|
match self.file_one(subject) {
|
||||||
|
Some(h) => {
|
||||||
|
if !on.iter().any(|x: &String| x == h) {
|
||||||
|
on.push(h.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => unfiled.push(subject.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Filed { on, unfiled }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a shelf made of a book's printed subjects.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub struct Filed {
|
||||||
|
/// Headings from the shelf, in the order the book's subjects hit them.
|
||||||
|
pub on: Vec<String>,
|
||||||
|
/// Subjects that matched no term, kept verbatim.
|
||||||
|
pub unfiled: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase, and reduce every run of non-alphanumerics to one space.
|
||||||
|
///
|
||||||
|
/// Cataloguing blocks separate with em dashes, en dashes, full stops and plain
|
||||||
|
/// hyphens, sometimes three of them in one heading, and OCR turns any of those
|
||||||
|
/// into any other. None of that should decide whether a book gets shelved.
|
||||||
|
fn fold(s: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(s.len());
|
||||||
|
let mut space = true; // no leading space
|
||||||
|
for c in s.chars() {
|
||||||
|
if c.is_alphanumeric() {
|
||||||
|
for l in c.to_lowercase() {
|
||||||
|
out.push(l);
|
||||||
|
}
|
||||||
|
space = false;
|
||||||
|
} else if !space {
|
||||||
|
out.push(' ');
|
||||||
|
space = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while out.ends_with(' ') {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `needle` appears in `hay` on whole-word boundaries.
|
||||||
|
///
|
||||||
|
/// Substring matching would file "Islam" under any book mentioning "Islamic"
|
||||||
|
/// — which is right — but would also file "Art" under "Cartography", which is
|
||||||
|
/// not. Both strings are already folded, so the boundary is a space.
|
||||||
|
fn contains_word(hay: &str, needle: &str) -> bool {
|
||||||
|
if needle.is_empty() || needle.len() > hay.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
hay.match_indices(needle).any(|(i, _)| {
|
||||||
|
let before = i == 0 || hay.as_bytes()[i - 1] == b' ';
|
||||||
|
let end = i + needle.len();
|
||||||
|
let after = end == hay.len() || hay.as_bytes()[end] == b' ';
|
||||||
|
before && after
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn shelf() -> Shelf {
|
||||||
|
Shelf::parse(
|
||||||
|
"# a shelf\n\
|
||||||
|
Philosophy = philosophy, philosophical\n\
|
||||||
|
Ancient Philosophy = ancient philosophy, plato, aristotle\n\
|
||||||
|
Political Science = political science, politics\n\
|
||||||
|
Theology = theology, christianity\n\
|
||||||
|
Byzantine History = byzantium, byzantine, constantinople\n\
|
||||||
|
Ethics\n",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_with_no_terms_matches_its_own_name() {
|
||||||
|
assert_eq!(shelf().file_one("Ethics — early works"), Some("Ethics"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_most_specific_term_wins() {
|
||||||
|
// Both "ancient philosophy" and "philosophy" match; the longer one has
|
||||||
|
// to win or every period files under the parent.
|
||||||
|
assert_eq!(
|
||||||
|
shelf().file_one("Ancient philosophy—History"),
|
||||||
|
Some("Ancient Philosophy")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn real_cataloguing_headings_file() {
|
||||||
|
let s = shelf();
|
||||||
|
for (raw, want) in [
|
||||||
|
(
|
||||||
|
"Political science – Early works to 1800",
|
||||||
|
"Political Science",
|
||||||
|
),
|
||||||
|
("Political science — Philosophy", "Political Science"),
|
||||||
|
("Byzantine Empire—History—527-1081", "Byzantine History"),
|
||||||
|
("Christianity and culture", "Theology"),
|
||||||
|
] {
|
||||||
|
assert_eq!(s.file_one(raw), Some(want), "{raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_dash_is_a_dash_however_it_was_printed() {
|
||||||
|
// em, en, hyphen, and the double hyphen OCR leaves behind.
|
||||||
|
let s = shelf();
|
||||||
|
for raw in [
|
||||||
|
"Philosophy—Introductions",
|
||||||
|
"Philosophy – Introductions",
|
||||||
|
"Philosophy - Introductions",
|
||||||
|
"Philosophy -- Introductions",
|
||||||
|
"Philosophy. Introductions",
|
||||||
|
] {
|
||||||
|
assert_eq!(s.file_one(raw), Some("Philosophy"), "{raw}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_term_must_match_a_whole_word() {
|
||||||
|
// "politics" must not file a book on "geopolitics", and "art" must not
|
||||||
|
// file one on cartography.
|
||||||
|
let s = Shelf::parse("Art\nPolitical Science = politics\n");
|
||||||
|
assert_eq!(s.file_one("Cartography—History"), None);
|
||||||
|
assert_eq!(s.file_one("Art—Byzantine"), Some("Art"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn what_matches_nothing_is_reported_rather_than_dropped() {
|
||||||
|
let filed = shelf().file(&[
|
||||||
|
"Political science – Early works to 1800".into(),
|
||||||
|
"Selectivity (Psychology)".into(),
|
||||||
|
]);
|
||||||
|
assert_eq!(filed.on, ["Political Science"]);
|
||||||
|
assert_eq!(filed.unfiled, ["Selectivity (Psychology)"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_heading_is_not_repeated() {
|
||||||
|
let filed = shelf().file(&["Philosophy—Ancient".into(), "Philosophy—Modern".into()]);
|
||||||
|
assert_eq!(filed.on, ["Philosophy"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_shelf_files_nothing_and_says_so() {
|
||||||
|
let s = Shelf::parse(" \n# nothing but a comment\n");
|
||||||
|
assert!(s.is_empty());
|
||||||
|
assert_eq!(s.file(&["Philosophy".into()]).unfiled, ["Philosophy"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
//! The title page, read as the block it is.
|
||||||
|
//!
|
||||||
|
//! A title page is laid out rather than written: the title, sometimes broken
|
||||||
|
//! across lines by nothing but the width of the page, then whoever wrote it,
|
||||||
|
//! then the publisher at the foot. Reading it is a matter of knowing where
|
||||||
|
//! the title stops, which is at the first line that is a person or a press.
|
||||||
|
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
use regex::Regex;
|
||||||
|
|
||||||
|
use crate::page::PERSON;
|
||||||
|
|
||||||
|
/// What a title page printed.
|
||||||
|
#[derive(Debug, Default, Clone, PartialEq)]
|
||||||
|
pub struct TitlePage {
|
||||||
|
/// The title, with lines the typesetter broke put back together.
|
||||||
|
pub title: Option<String>,
|
||||||
|
/// What followed it, where the page gave a subtitle.
|
||||||
|
pub subtitle: Option<String>,
|
||||||
|
/// Whoever it says wrote the book.
|
||||||
|
pub authors: Vec<String>,
|
||||||
|
/// Whoever it says edited it.
|
||||||
|
pub editors: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(1714)`, `, 1922-2019` — when a title page dates its author.
|
||||||
|
static LIFESPAN: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"\s*[(,]\s*(?:1[0-9]|20)\d{2}\s*(?:[-\u{2013}]\s*(?:(?:1[0-9]|20)\d{2})?)?\s*\)?\s*$",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `350 BC`, `1971` — a date alone on a line, which is not a title.
|
||||||
|
static BARE_DATE: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"(?i)^\d{1,4}\s*(bc|ad|bce|ce)?\.?$").expect("a valid pattern"));
|
||||||
|
/// `Copyright © 2013 by`, `\u{a9} 2013 Richard Swinburne`, `Copyright 1971 by`.
|
||||||
|
/// `1. General Remarks`, `2 James W. McAllister` — a numbered heading.
|
||||||
|
static NUMBERED_HEADING: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^\s*\d+\s*[.)]\s+\S|^\s*\d+\s{2,}\S").expect("a valid pattern"));
|
||||||
|
/// `5222 What Is This`, `827 North Fourth Street` — a number that is not a
|
||||||
|
/// year, leading a line.
|
||||||
|
static LEADING_NUMBER: LazyLock<Regex> =
|
||||||
|
LazyLock::new(|| Regex::new(r"^\s*(\d{1,5})\s+\S").expect("a valid pattern"));
|
||||||
|
/// `Page xxxvi`, `Chapter I`, `Fig. 3` — a marker, not a name.
|
||||||
|
static MARKER: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)^\s*(page|pages|p\.|pp\.|fig\.|figure|plate|chapter|ch\.|section|part)\s+[\divxlcm]",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `Sidebottom, Harry. Ancient warfare` — a bibliography entry, which front
|
||||||
|
/// matter prints in its cataloguing block and in its further reading.
|
||||||
|
static CATALOGUE_ENTRY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"^[A-Z][A-Za-z'\-]+,\s+[A-Z][A-Za-z'.\-]*\.\s+\S").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `To Mackenzie,` — a dedication, set alone on its own page.
|
||||||
|
static DEDICATION: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^\s*(to|for|in memory of)\s+\S.*,\s*$").expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static COPYRIGHT_NOTICE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
// The year need not follow the symbol directly: "(c) Alfredo Ferrarin
|
||||||
|
// 2004" puts the holder between them, and was read as a title.
|
||||||
|
Regex::new(r"(?i)(\u{a9}|\(c\)|copyright)[^\n]{0,60}?(1[0-9]|20)\d{2}")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// `Edited by`, with whoever it was set on the line beneath.
|
||||||
|
static ROLE_ONLY: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(edited|translated|written|selected|compiled)\s+by:?$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
static BYLINE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(r"(?i)^(?P<role>by|edited by|written by|translated by|selected by|compiled by)\s+(?P<who>.{3,90})$")
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A press, an imprint, or the line that announces one. The foot of the page.
|
||||||
|
pub static IMPRINT: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)\b(university press|[a-z]+ press\b|publish(?:er|ers|ing)\b|publications\b|\
|
||||||
|
verlag\b|éditions\b|editions\b|books? (?:ltd|inc|group)\b|\
|
||||||
|
routledge|blackwell|wiley|springer|palgrave|macmillan|penguin|\
|
||||||
|
random house|harpercollins|w\.? ?w\.? norton|doubleday|bloomsbury|eerdmans|\
|
||||||
|
brill\b|sage\b|vintage\b|knopf|faber|crossway|zondervan|clarendon|\
|
||||||
|
foundation\b|institute\b|society\b|trust\b|distributed by|\
|
||||||
|
cover (design|art|photograph)|jacket design|thank you for buying|\
|
||||||
|
typeset|designed by|illustrated by|printed by|\
|
||||||
|
copyright|all rights reserved|printed in)\b",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// A city a publisher puts on a title page, alone on its line.
|
||||||
|
pub static PLACE: LazyLock<Regex> = LazyLock::new(|| {
|
||||||
|
Regex::new(
|
||||||
|
r"(?i)^(?P<city>oxford|cambridge|london|new york|new haven|chicago|boston|princeton|\
|
||||||
|
berlin|paris|munich|rome|madrid|grand rapids|edinburgh|dublin|toronto|sydney|\
|
||||||
|
melbourne|minneapolis|philadelphia|san francisco|los angeles|leiden|amsterdam|\
|
||||||
|
copenhagen|stockholm|moscow|beijing|tokyo|delhi|nairobi)\b[\s,·•|and]*",
|
||||||
|
)
|
||||||
|
.expect("a valid pattern")
|
||||||
|
});
|
||||||
|
/// Words that name a part of a book rather than the book.
|
||||||
|
static STRUCTURAL: &[&str] = &[
|
||||||
|
"abbreviations",
|
||||||
|
"about the author",
|
||||||
|
"abstract",
|
||||||
|
"acknowledgements",
|
||||||
|
"acknowledgments",
|
||||||
|
"afterword",
|
||||||
|
"appendix",
|
||||||
|
"author",
|
||||||
|
"authors",
|
||||||
|
"bibliography",
|
||||||
|
"contents",
|
||||||
|
"dedication",
|
||||||
|
"epigraph",
|
||||||
|
"epilogue",
|
||||||
|
"foreword",
|
||||||
|
"frontispiece",
|
||||||
|
"glossary",
|
||||||
|
"half title",
|
||||||
|
"illustrations",
|
||||||
|
"index",
|
||||||
|
"introduction",
|
||||||
|
"keywords",
|
||||||
|
"list of contributors",
|
||||||
|
"list of illustrations",
|
||||||
|
"maps",
|
||||||
|
"notes",
|
||||||
|
"postscript",
|
||||||
|
"preface",
|
||||||
|
"prologue",
|
||||||
|
"subtitle",
|
||||||
|
"table of contents",
|
||||||
|
"title",
|
||||||
|
"title page",
|
||||||
|
"translator's note",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_structural(line: &str) -> bool {
|
||||||
|
let norm: String = line
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
|
||||||
|
.collect::<String>()
|
||||||
|
.trim()
|
||||||
|
.to_lowercase();
|
||||||
|
let norm = strip_folio(&norm);
|
||||||
|
STRUCTURAL.binary_search(&norm).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A heading with its page number taken off either end.
|
||||||
|
///
|
||||||
|
/// Front matter is folioed in roman, and the folio sits on the same line as
|
||||||
|
/// the heading often enough to matter: `vi CONTENTS` and `viii CONTENTS`
|
||||||
|
/// are contents pages, and were being read as titles because they are not the
|
||||||
|
/// bare word.
|
||||||
|
fn strip_folio(norm: &str) -> &str {
|
||||||
|
let is_folio = |w: &str| {
|
||||||
|
!w.is_empty()
|
||||||
|
&& (w.chars().all(|c| c.is_ascii_digit())
|
||||||
|
|| w.chars()
|
||||||
|
.all(|c| matches!(c, 'i' | 'v' | 'x' | 'l' | 'c' | 'd' | 'm')))
|
||||||
|
};
|
||||||
|
let mut out = norm.trim();
|
||||||
|
loop {
|
||||||
|
let trimmed = match out.split_once(' ') {
|
||||||
|
Some((head, rest)) if is_folio(head) => rest.trim_start(),
|
||||||
|
_ => match out.rsplit_once(' ') {
|
||||||
|
Some((rest, tail)) if is_folio(tail) => rest.trim_end(),
|
||||||
|
_ => return out,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if trimmed == out {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out = trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lines that are printed matter rather than anything a book is called.
|
||||||
|
///
|
||||||
|
/// Every one of these was read as a title off a real book in a real library:
|
||||||
|
/// a copyright notice, a translator's credit, a part heading in brackets, a
|
||||||
|
/// span of HTML a converter left behind, and a volume entry from a collected
|
||||||
|
/// edition's own list of volumes.
|
||||||
|
fn is_furniture(line: &str) -> bool {
|
||||||
|
let t = line.trim();
|
||||||
|
if t.starts_with('[') || t.starts_with('\\') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A converter that left its markup in. Nothing printed on a title page is
|
||||||
|
// a tag, a stylesheet declaration, a markdown link or a filename, and a
|
||||||
|
// title that appears to be one is a fragment of the conversion rather than
|
||||||
|
// of the book. Rejecting only the first such line is no use: the next line
|
||||||
|
// of a mangled page is mangled too, so the whole shape has to go.
|
||||||
|
if t.contains("<span")
|
||||||
|
|| t.contains("<div")
|
||||||
|
|| t.contains("<p ")
|
||||||
|
|| t.contains("</")
|
||||||
|
|| t.contains("](") // a markdown link the converter split
|
||||||
|
|| t.contains(""")
|
||||||
|
|| t.contains("&")
|
||||||
|
|| t.contains("mso-") // Word's stylesheet, via HTML export
|
||||||
|
|| t.contains("font-family")
|
||||||
|
|| t.contains("font-size")
|
||||||
|
|| t.contains("style=")
|
||||||
|
|| t.contains("_epub_")
|
||||||
|
|| t.contains(".html")
|
||||||
|
|| t.contains(".xhtml")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Sentences a copyright page sets, which are not notices and so not caught
|
||||||
|
// by the notice pattern.
|
||||||
|
if NUMBERED_HEADING.is_match(t) || MARKER.is_match(t) || CATALOGUE_ENTRY.is_match(t) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// A line opening with a number that is not a year. "5222 What Is This" and
|
||||||
|
// "827 North Fourth Street" are a stray folio and an address; "1984
|
||||||
|
// Revisited" is a book, and the difference is whether the number could be
|
||||||
|
// the year of one.
|
||||||
|
if let Some(c) = LEADING_NUMBER.captures(t) {
|
||||||
|
let n: u32 = c[1].parse().unwrap_or(0);
|
||||||
|
if !(1000..=2099).contains(&n) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if DEDICATION.is_match(t) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let lower = t.to_lowercase();
|
||||||
|
for phrase in [
|
||||||
|
"moral rights of the author",
|
||||||
|
"no part of this publication",
|
||||||
|
"british library cataloguing",
|
||||||
|
"library of congress cataloging",
|
||||||
|
"printed in the united",
|
||||||
|
"typeset by",
|
||||||
|
"a cip catalogue record",
|
||||||
|
] {
|
||||||
|
if lower.contains(phrase) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let low = t.to_lowercase();
|
||||||
|
// The notice, not the word. "Copyright and Its Discontents" is a book;
|
||||||
|
// "Copyright © 2013 by" is the page it would be printed on. What tells
|
||||||
|
// them apart is the symbol or the year, never the word alone.
|
||||||
|
if COPYRIGHT_NOTICE.is_match(t)
|
||||||
|
|| low.contains("all rights reserved")
|
||||||
|
|| low.starts_with("first published")
|
||||||
|
|| low.starts_with("reprinted")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// "Translated from the French by Willard R. Trask" — a credit, not a name
|
||||||
|
// and not a title. ROLE_ONLY catches the bare "Translated by"; this is the
|
||||||
|
// whole sentence, which a title page sets on one line.
|
||||||
|
if low.starts_with("translated from") || low.starts_with("translated by") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// "Vol. 11. HENRY JAMES, JR." — an entry in a collected edition's list of
|
||||||
|
// its own volumes, printed in the front matter of every volume.
|
||||||
|
if low.starts_with("vol.") || low.starts_with("volume ") && low.contains('.') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn people(who: &str) -> Vec<String> {
|
||||||
|
who.split(&[';', '&'][..])
|
||||||
|
.flat_map(|part| part.split(" and "))
|
||||||
|
.map(|name| name.trim().trim_end_matches(['.', ',']).trim().to_string())
|
||||||
|
.filter(|name| name.len() >= 3 && name.split_whitespace().count() <= 5)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Words a line ends on when the typesetter broke a title across it.
|
||||||
|
///
|
||||||
|
/// `Esotericism in` / `Western Culture` is one title set on two lines, and
|
||||||
|
/// nothing but the dangling preposition says so.
|
||||||
|
const CONTINUES: &[&str] = &[
|
||||||
|
"a", "an", "and", "at", "between", "by", "for", "from", "in", "into", "of", "on", "or", "the",
|
||||||
|
"to", "under", "with", "without",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Endings no surname has and every abstract noun does.
|
||||||
|
///
|
||||||
|
/// Two capitalised words look exactly like a name — `Martin Cohen` and
|
||||||
|
/// `Political Philosophy` are the same shape — and on a title page the
|
||||||
|
/// second one is the top half of the title. English morphology is what
|
||||||
|
/// tells them apart without a dictionary of names to consult.
|
||||||
|
const NOT_A_NAME: &[&str] = &[
|
||||||
|
"ance", "ence", "ics", "ism", "ity", "ment", "ness", "ography", "ology", "osophy", "sion",
|
||||||
|
"tion", "ture",
|
||||||
|
];
|
||||||
|
|
||||||
|
fn could_be_a_person(line: &str) -> bool {
|
||||||
|
let line = LIFESPAN.replace(line, "");
|
||||||
|
let line = line.trim().trim_end_matches('.');
|
||||||
|
if !PERSON.is_match(line) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let tokens: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if tokens.len() > 4 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
!tokens.iter().any(|token| {
|
||||||
|
let word = token.trim_end_matches('.').to_lowercase();
|
||||||
|
word.len() > 4 && NOT_A_NAME.iter().any(|end| word.ends_with(end))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wraps(line: &str) -> bool {
|
||||||
|
if line.ends_with('-') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
line.rsplit_once(char::is_whitespace)
|
||||||
|
.is_some_and(|(_, last)| CONTINUES.contains(&last.to_lowercase().as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Put a title the page broke across lines back together.
|
||||||
|
fn unwrap_lines(block: &[&str]) -> Vec<String> {
|
||||||
|
let mut out: Vec<String> = Vec::new();
|
||||||
|
for line in block {
|
||||||
|
match out.last_mut() {
|
||||||
|
Some(previous) if wraps(previous) => {
|
||||||
|
if previous.ends_with('-') {
|
||||||
|
previous.pop();
|
||||||
|
previous.push_str(line);
|
||||||
|
} else {
|
||||||
|
previous.push(' ');
|
||||||
|
previous.push_str(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => out.push((*line).to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a title page, or decline to.
|
||||||
|
///
|
||||||
|
/// The page is taken as a block that ends at the imprint — a press, a place,
|
||||||
|
/// or the first line of running prose. What is left is the title, whoever
|
||||||
|
/// wrote it, and nothing else, because that is all a title page holds.
|
||||||
|
///
|
||||||
|
/// Where the byline sits is the publisher's choice and both choices are
|
||||||
|
/// common, so it is looked for at the top of the block and at the bottom and
|
||||||
|
/// nowhere between: a name in the middle of a title page is a title that
|
||||||
|
/// happens to be two capitalised words.
|
||||||
|
///
|
||||||
|
/// Declines when the block is empty or runs past five lines, at which point
|
||||||
|
/// this is a page of something else and a title taken off it would be a
|
||||||
|
/// guess with a page number attached.
|
||||||
|
pub fn read(lines: &[String]) -> Option<TitlePage> {
|
||||||
|
let mut out = TitlePage::default();
|
||||||
|
let mut block: Vec<&str> = Vec::new();
|
||||||
|
let mut waiting: Option<String> = None;
|
||||||
|
|
||||||
|
for line in lines.iter().take(14) {
|
||||||
|
// `Edited by` on its own line, with the names set under it.
|
||||||
|
if let Some(role) = waiting.take() {
|
||||||
|
let who = people(line);
|
||||||
|
if !who.is_empty() {
|
||||||
|
if role.starts_with("edited") {
|
||||||
|
out.editors.extend(who);
|
||||||
|
} else if !role.starts_with("translated") {
|
||||||
|
out.authors.extend(who);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_structural(line) || is_furniture(line) || BARE_DATE.is_match(line) {
|
||||||
|
if block.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if let Some(caps) = BYLINE.captures(line) {
|
||||||
|
let role = caps["role"].to_lowercase();
|
||||||
|
let who = people(&caps["who"]);
|
||||||
|
if who.is_empty() {
|
||||||
|
waiting = Some(role);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if role.starts_with("edited") {
|
||||||
|
out.editors.extend(who);
|
||||||
|
} else if !role.starts_with("translated") {
|
||||||
|
out.authors.extend(who);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ROLE_ONLY.is_match(line) {
|
||||||
|
waiting = Some(line.to_lowercase());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if IMPRINT.is_match(line) || PLACE.is_match(line) {
|
||||||
|
if block.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
block.push(line);
|
||||||
|
if block.len() > 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if block.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The byline is above the title or below it, never inside it.
|
||||||
|
if out.authors.is_empty() && out.editors.is_empty() && block.len() > 1 {
|
||||||
|
if could_be_a_person(block[0]) {
|
||||||
|
out.authors = people(block[0]);
|
||||||
|
block.remove(0);
|
||||||
|
} else if could_be_a_person(block[block.len() - 1]) {
|
||||||
|
out.authors = people(block[block.len() - 1]);
|
||||||
|
block.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let units = unwrap_lines(&block);
|
||||||
|
let mut title = units.first()?.clone();
|
||||||
|
let mut subtitle = units.get(1).cloned();
|
||||||
|
let split = title
|
||||||
|
.split_once(" : ")
|
||||||
|
.or_else(|| title.split_once(": "))
|
||||||
|
.map(|(head, tail)| (head.trim().to_string(), tail.trim().to_string()));
|
||||||
|
if let Some((head, tail)) = split {
|
||||||
|
title = head;
|
||||||
|
subtitle = Some(tail);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fragment is not a title. Two letters of one, left behind by a font
|
||||||
|
// the extractor could not read, is worse than nothing: it is a title
|
||||||
|
// page's authority attached to noise.
|
||||||
|
out.title = Some(title).filter(|t| {
|
||||||
|
t.chars().filter(|c| c.is_alphabetic()).count() >= 4
|
||||||
|
&& t.split_whitespace()
|
||||||
|
.any(|w| w.chars().filter(|c| c.is_alphabetic()).count() >= 3)
|
||||||
|
});
|
||||||
|
out.subtitle = subtitle.filter(|s| s.len() >= 2);
|
||||||
|
out.title.as_ref()?;
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod furniture_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Every string here was read as a title off a book in a real library.
|
||||||
|
#[test]
|
||||||
|
fn printed_matter_is_not_a_title() {
|
||||||
|
for line in [
|
||||||
|
"vi CONTENTS",
|
||||||
|
"viii CONTENTS",
|
||||||
|
"xiv ACKNOWLEDGEMENTS",
|
||||||
|
"12 INDEX",
|
||||||
|
"Translated from the French by Willard R. Trask",
|
||||||
|
"Copyright © 2013 by Barry Davies, BEM",
|
||||||
|
"© Alfredo Ferrarin 2004",
|
||||||
|
"\u{a9} Cambridge University Press 2011",
|
||||||
|
"[PART ONE:",
|
||||||
|
"Piper Verlag GmbH, Munich<span style=\"font-size\">",
|
||||||
|
"Vol. 11. HENRY JAMES, JR.",
|
||||||
|
"All rights reserved",
|
||||||
|
"First published 1971",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_structural(line) || is_furniture(line),
|
||||||
|
"read as a title: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_real_title_survives_all_of_that() {
|
||||||
|
for line in [
|
||||||
|
"The First World War",
|
||||||
|
"A Very Short Introduction",
|
||||||
|
"Christianity and Classical Culture",
|
||||||
|
"The Problems of Philosophy",
|
||||||
|
"Volume and Void",
|
||||||
|
"Contents of Consciousness",
|
||||||
|
"August 1914",
|
||||||
|
"Copyright and Its Discontents",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!(is_structural(line) || is_furniture(line)),
|
||||||
|
"rejected a real title: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_folio_comes_off_either_end() {
|
||||||
|
assert_eq!(strip_folio("vi contents"), "contents");
|
||||||
|
assert_eq!(strip_folio("contents vi"), "contents");
|
||||||
|
assert_eq!(strip_folio("contents"), "contents");
|
||||||
|
// A title that happens to be roman numerals throughout is not a folio
|
||||||
|
// with nothing left; it keeps whatever it had.
|
||||||
|
assert_eq!(strip_folio("i"), "i");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod conversion_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A mangled page is mangled on every line, so rejecting the first
|
||||||
|
/// leftover only moves the misread down one. Each of these is the line
|
||||||
|
/// colophon fell through to after the line above it was rejected.
|
||||||
|
#[test]
|
||||||
|
fn what_a_converter_left_behind_is_never_a_title() {
|
||||||
|
for line in [
|
||||||
|
"[PART ONE:",
|
||||||
|
"RULERS AND REDNECKS](Webb_9780767922951_epub_p01_r1.html)",
|
||||||
|
"[AFRICAN SLAVERY IN AMERICA - \\[1775\\]](index_split_004.html)",
|
||||||
|
"Piper Verlag GmbH, Munich<span style=\"font-family:"Times\"",
|
||||||
|
"mso-bidi-font-family:"Times New Roman";mso-ansi-language",
|
||||||
|
"The moral rights of the author have been asserted",
|
||||||
|
"No part of this publication may be reproduced",
|
||||||
|
"British Library Cataloguing in Publication Data",
|
||||||
|
"Typeset by SPI Publisher Services",
|
||||||
|
] {
|
||||||
|
assert!(is_furniture(line), "read as a title: {line:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_title_that_merely_sounds_like_the_copyright_page_survives() {
|
||||||
|
for line in [
|
||||||
|
"Copyright and Its Discontents",
|
||||||
|
"The Moral Rights of Man",
|
||||||
|
"Printed Matter",
|
||||||
|
"A Publication of the Institute",
|
||||||
|
] {
|
||||||
|
assert!(!is_furniture(line), "rejected a real title: {line:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod review_pile_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Everything here was proposed as a title by a real run over this
|
||||||
|
/// library, and sat in the review queue waiting for a person to say no.
|
||||||
|
#[test]
|
||||||
|
fn what_the_review_pile_was_full_of() {
|
||||||
|
for line in [
|
||||||
|
"1. General Remarks",
|
||||||
|
"2 James W. McAllister",
|
||||||
|
"5222 What Is This",
|
||||||
|
"827 North Fourth Street",
|
||||||
|
"Page xxxvi",
|
||||||
|
"Page xxxviii",
|
||||||
|
"Chapter I",
|
||||||
|
"title",
|
||||||
|
"Sidebottom, Harry. Ancient warfare",
|
||||||
|
"To Mackenzie,",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
is_structural(line) || is_furniture(line),
|
||||||
|
"still read as a title: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// And none of it catches a book. The numbers are the delicate part: a
|
||||||
|
/// year can open a title, and a folio cannot.
|
||||||
|
#[test]
|
||||||
|
fn a_book_is_still_a_book() {
|
||||||
|
for line in [
|
||||||
|
"1984 Revisited",
|
||||||
|
"1914: The Year the World Ended",
|
||||||
|
"Chapter and Verse",
|
||||||
|
"Part of the Furniture",
|
||||||
|
"To Kill a Mockingbird",
|
||||||
|
"For Whom the Bell Tolls",
|
||||||
|
"The Title of This Book",
|
||||||
|
"Pages from a Cold Island",
|
||||||
|
"Sections of a Broken Whole",
|
||||||
|
"In Memory of Bread",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!(is_structural(line) || is_furniture(line)),
|
||||||
|
"rejected a real title: {line:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
//! What real front matter looks like, and what has to come off it.
|
||||||
|
//!
|
||||||
|
//! Every case here is a shape taken from a book that was catalogued wrongly
|
||||||
|
//! without it.
|
||||||
|
|
||||||
|
use colophon::{PAGES, Source, read};
|
||||||
|
|
||||||
|
/// A paperback series prints two hundred other titles before its own, in the
|
||||||
|
/// same shape a title page uses. Catalogued off that page, this book is by
|
||||||
|
/// whichever author the reader's eye landed on.
|
||||||
|
#[test]
|
||||||
|
fn a_series_advertisement_is_not_the_title_page() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 2 -->
|
||||||
|
|
||||||
|
The First World War: A Very Short Introduction
|
||||||
|
|
||||||
|
<!-- Page 3 -->
|
||||||
|
|
||||||
|
Very Short Introductions available now:
|
||||||
|
ANARCHISM Colin Ward
|
||||||
|
ANCIENT EGYPT Ian Shaw
|
||||||
|
ARISTOTLE Jonathan Barnes
|
||||||
|
THE ELEMENTS Philip Ball
|
||||||
|
CONSCIOUSNESS Susan Blackmore
|
||||||
|
COSMOLOGY Peter Coles
|
||||||
|
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
CLASSICS Mary Beard
|
||||||
|
DADA AND SURREALISM David Hopkins
|
||||||
|
DARWIN Jonathan Howard
|
||||||
|
THE DEAD SEA SCROLLS Timothy Lim
|
||||||
|
RUSSIAN LITERATURE Catriona Kelly
|
||||||
|
|
||||||
|
<!-- Page 6 -->
|
||||||
|
|
||||||
|
Michael Howard
|
||||||
|
|
||||||
|
THE FIRST
|
||||||
|
WORLD WAR
|
||||||
|
|
||||||
|
A Very Short Introduction
|
||||||
|
|
||||||
|
<!-- Page 7 -->
|
||||||
|
|
||||||
|
Great Clarendon Street, Oxford ox2 6dp
|
||||||
|
Oxford University Press is a department of the University of Oxford.
|
||||||
|
First published 2002
|
||||||
|
All rights reserved
|
||||||
|
ISBN 978-0-19-280353-5
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
// The title page breaks the title across two lines and sets it in
|
||||||
|
// capitals; the half title prints the whole of it, cased as the
|
||||||
|
// publisher meant. Both are this book, and the unbroken one is kept.
|
||||||
|
assert_eq!(found.title.as_deref(), Some("The First World War"));
|
||||||
|
assert_eq!(found.subtitle.as_deref(), Some("A Very Short Introduction"));
|
||||||
|
assert_eq!(found.authors, vec!["Michael Howard"]);
|
||||||
|
assert_eq!(found.isbns, vec!["9780192803535"]);
|
||||||
|
assert_eq!(found.year, Some(2002));
|
||||||
|
assert_eq!(found.read_from.get("title"), Some(&Source::TitlePage));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cataloguing block is a catalogue record somebody else already made.
|
||||||
|
/// The solidus between the title and who wrote it is read as a capital I by
|
||||||
|
/// every scanner ever built.
|
||||||
|
#[test]
|
||||||
|
fn a_cataloguing_block_outranks_the_page_it_is_printed_on() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
Consciousness Explained
|
||||||
|
|
||||||
|
Daniel C. Dennett
|
||||||
|
|
||||||
|
<!-- Page 5 -->
|
||||||
|
|
||||||
|
Copyright © 1991 by Daniel C. Dennett
|
||||||
|
All rights reserved
|
||||||
|
First published 1991
|
||||||
|
Library of Congress Cataloging-in-Publication Data
|
||||||
|
Dennett, Daniel C.
|
||||||
|
Consciousness explained I Daniel C. Dennett. — 1st ed.
|
||||||
|
p. cm.
|
||||||
|
Includes bibliographical references and index.
|
||||||
|
ISBN 0-316-18065-3
|
||||||
|
1. Consciousness. 2. Philosophy of mind. I. Title.
|
||||||
|
B105.C477D46 1991
|
||||||
|
126—dc20
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert_eq!(found.title.as_deref(), Some("Consciousness explained"));
|
||||||
|
assert_eq!(found.authors, vec!["Daniel C. Dennett"]);
|
||||||
|
assert_eq!(found.read_from.get("title"), Some(&Source::Cip));
|
||||||
|
assert_eq!(found.subjects, vec!["Consciousness", "Philosophy of mind"]);
|
||||||
|
assert_eq!(found.isbns, vec!["0316180653"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Since 2013 the block comes in labelled fields, and the subject headings
|
||||||
|
/// with it — the one place a book states what it is about.
|
||||||
|
#[test]
|
||||||
|
fn the_labelled_block_gives_its_subjects() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
Library of Congress Cataloging-in-Publication Data
|
||||||
|
Names: Howard, Michael, 1922- author.
|
||||||
|
Title: The First World War : a very short introduction / Michael Howard.
|
||||||
|
Description: Oxford : Oxford University Press, 2007.
|
||||||
|
Identifiers: ISBN 9780192803535
|
||||||
|
Subjects: LCSH: World War, 1914-1918. | Europe--History--1871-1918.
|
||||||
|
All rights reserved
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert_eq!(found.title.as_deref(), Some("The First World War"));
|
||||||
|
assert_eq!(found.subtitle.as_deref(), Some("a very short introduction"));
|
||||||
|
assert_eq!(found.authors, vec!["Michael Howard"]);
|
||||||
|
assert_eq!(found.publisher.as_deref(), Some("Oxford University Press"));
|
||||||
|
assert_eq!(found.place.as_deref(), Some("Oxford"));
|
||||||
|
assert_eq!(found.year, Some(2007));
|
||||||
|
assert_eq!(found.subjects.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A translation's block prints the original title in brackets above the
|
||||||
|
/// real one. It is a heading, not the book in your hand.
|
||||||
|
#[test]
|
||||||
|
fn a_uniform_title_is_not_the_title() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
All rights reserved
|
||||||
|
Library of Congress Cataloging-in-Publication Data
|
||||||
|
Ricoeur, Paul.
|
||||||
|
[Sur la traduction. English]
|
||||||
|
On translation / Paul Ricoeur ; translated by Eileen Brennan.
|
||||||
|
ISBN 0-415-35777-X
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert_eq!(found.title.as_deref(), Some("On translation"));
|
||||||
|
assert_eq!(found.authors, vec!["Paul Ricoeur"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A title broken across two lines by the width of the page, with the second
|
||||||
|
/// half looking exactly like a person's name.
|
||||||
|
#[test]
|
||||||
|
fn a_wrapped_title_is_one_title() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
Esotericism in
|
||||||
|
Western Culture
|
||||||
|
|
||||||
|
Counter-Normativity and
|
||||||
|
Rejected Knowledge
|
||||||
|
|
||||||
|
Wouter J. Hanegraaff
|
||||||
|
|
||||||
|
<!-- Page 5 -->
|
||||||
|
|
||||||
|
BLOOMSBURY ACADEMIC
|
||||||
|
First published 2012
|
||||||
|
All rights reserved
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert_eq!(
|
||||||
|
found.title.as_deref(),
|
||||||
|
Some("Esotericism in Western Culture")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
found.subtitle.as_deref(),
|
||||||
|
Some("Counter-Normativity and Rejected Knowledge")
|
||||||
|
);
|
||||||
|
assert_eq!(found.authors, vec!["Wouter J. Hanegraaff"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Two capitalised words are a name or they are half a title, and English
|
||||||
|
/// morphology is the only thing on the page that says which.
|
||||||
|
#[test]
|
||||||
|
fn an_abstract_noun_is_not_a_person() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 1 -->
|
||||||
|
|
||||||
|
Political Philosophy
|
||||||
|
From Plato to Mao
|
||||||
|
Martin Cohen
|
||||||
|
Pluto Press
|
||||||
|
LONDON
|
||||||
|
|
||||||
|
<!-- Page 2 -->
|
||||||
|
|
||||||
|
First published 2001 by Pluto Press
|
||||||
|
All rights reserved
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert_eq!(found.title.as_deref(), Some("Political Philosophy"));
|
||||||
|
assert_eq!(found.subtitle.as_deref(), Some("From Plato to Mao"));
|
||||||
|
assert_eq!(found.authors, vec!["Martin Cohen"]);
|
||||||
|
assert_eq!(found.publisher.as_deref(), Some("Pluto Press"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A scan that starts at the preface prints no title page, and the honest
|
||||||
|
/// record of that is an empty one.
|
||||||
|
#[test]
|
||||||
|
fn a_book_that_says_nothing_about_itself_is_reported_as_saying_nothing() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 2 -->
|
||||||
|
|
||||||
|
Preface
|
||||||
|
|
||||||
|
Some mental states have qualitative phenomenal characters, and since the
|
||||||
|
dawn of Behaviorism some philosophers have doubted the ability of any
|
||||||
|
physicalist account of the mental to accommodate this fact.
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert!(found.is_empty());
|
||||||
|
assert!(found.title.is_none());
|
||||||
|
for field in ["title", "authors", "isbns", "publisher", "year", "subjects"] {
|
||||||
|
assert!(
|
||||||
|
found.unsettled.iter().any(|f| f == field),
|
||||||
|
"{field} should be unsettled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An ISBN whose check digit disagrees is a mistyped one, and reporting it
|
||||||
|
/// would hand the next stage a key that opens nothing.
|
||||||
|
#[test]
|
||||||
|
fn an_isbn_that_fails_its_check_digit_is_not_reported() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 3 -->
|
||||||
|
|
||||||
|
All rights reserved
|
||||||
|
First published 1999
|
||||||
|
ISBN 978-0-19-280353-9
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
assert!(found.isbns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a model answers is filled in only where the pages said nothing, and
|
||||||
|
/// is marked as having come from a model.
|
||||||
|
#[test]
|
||||||
|
fn a_reply_fills_only_what_the_pages_left() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 4 -->
|
||||||
|
|
||||||
|
Esotericism in
|
||||||
|
Western Culture
|
||||||
|
|
||||||
|
Wouter J. Hanegraaff
|
||||||
|
|
||||||
|
<!-- Page 5 -->
|
||||||
|
|
||||||
|
All rights reserved
|
||||||
|
First published 2012
|
||||||
|
";
|
||||||
|
let mut found = read(front, PAGES);
|
||||||
|
assert!(found.subjects.is_empty());
|
||||||
|
let filled = colophon::prompt::merge(
|
||||||
|
&mut found,
|
||||||
|
"{\"title\": \"Something Else\", \"subjects\": [\"Occultism\", \"Europe\"]}",
|
||||||
|
)
|
||||||
|
.expect("a readable reply");
|
||||||
|
assert_eq!(filled, 1);
|
||||||
|
assert_eq!(
|
||||||
|
found.title.as_deref(),
|
||||||
|
Some("Esotericism in Western Culture")
|
||||||
|
);
|
||||||
|
assert_eq!(found.subjects, vec!["Occultism", "Europe"]);
|
||||||
|
assert_eq!(found.read_from.get("subjects"), Some(&Source::Model));
|
||||||
|
assert!(!found.unsettled.iter().any(|f| f == "subjects"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The question a model is asked never includes the pages that would
|
||||||
|
/// mislead it.
|
||||||
|
#[test]
|
||||||
|
fn the_question_leaves_out_the_advertising() {
|
||||||
|
let front = "\
|
||||||
|
<!-- Page 3 -->
|
||||||
|
|
||||||
|
Very Short Introductions available now:
|
||||||
|
ANARCHISM Colin Ward
|
||||||
|
THE ELEMENTS Philip Ball
|
||||||
|
CONSCIOUSNESS Susan Blackmore
|
||||||
|
ARISTOTLE Jonathan Barnes
|
||||||
|
COSMOLOGY Peter Coles
|
||||||
|
|
||||||
|
<!-- Page 6 -->
|
||||||
|
|
||||||
|
Some pages of a preface that names no title at all, running on for long
|
||||||
|
enough that the classifier takes it for text rather than a title page.
|
||||||
|
";
|
||||||
|
let found = read(front, PAGES);
|
||||||
|
let question = colophon::prompt::compose(&found, front, PAGES).expect("a question");
|
||||||
|
assert!(!question.contains("THE ELEMENTS"));
|
||||||
|
assert!(!question.contains("Philip Ball"));
|
||||||
|
assert!(question.contains("wanted: title"));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ this suite, because a real pipeline has a database at the end of it.
|
|||||||
|
|
||||||
- [A shelf of files, sorted before anything else touches it](#a-shelf-of-files-sorted-before-anything-else-touches-it)
|
- [A shelf of files, sorted before anything else touches it](#a-shelf-of-files-sorted-before-anything-else-touches-it)
|
||||||
- [A catalogue out of what the files already say](#a-catalogue-out-of-what-the-files-already-say)
|
- [A catalogue out of what the files already say](#a-catalogue-out-of-what-the-files-already-say)
|
||||||
|
- [Shelving a library by what its books printed](#shelving-a-library-by-what-its-books-printed)
|
||||||
- [Asking a model, without this suite calling one](#asking-a-model-without-this-suite-calling-one)
|
- [Asking a model, without this suite calling one](#asking-a-model-without-this-suite-calling-one)
|
||||||
- [A scanned book into searchable passages](#a-scanned-book-into-searchable-passages)
|
- [A scanned book into searchable passages](#a-scanned-book-into-searchable-passages)
|
||||||
- [A collected edition into one record per work](#a-collected-edition-into-one-record-per-work)
|
- [A collected edition into one record per work](#a-collected-edition-into-one-record-per-work)
|
||||||
@@ -98,6 +99,97 @@ with:
|
|||||||
langid converted/*.md | jq -r 'select(.language != "en") | [.path, .name] | @tsv'
|
langid converted/*.md | jq -r 'select(.language != "en") | [.path, .name] | @tsv'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Shelving a library by what its books printed
|
||||||
|
|
||||||
|
A catalogue's subjects come from wherever the file came from, which is to say
|
||||||
|
from nowhere in particular. A book's own cataloguing block is the one place its
|
||||||
|
subjects are stated rather than guessed — and stated in Library of Congress
|
||||||
|
form, which is precise and useless for browsing:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ colophon converted/hobbes.md | jq -r '.subjects[]'
|
||||||
|
Hobbes, Thomas, 1588–1679. Leviathan
|
||||||
|
Political science – Early works to 1800
|
||||||
|
```
|
||||||
|
|
||||||
|
Nobody has a shelf called that. Give `colophon` the list you actually file by
|
||||||
|
and it files them for you:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat shelf.txt
|
||||||
|
Political Science = political science, politics
|
||||||
|
Byzantine History = byzantium, byzantine, constantinople
|
||||||
|
Theology = theology, christianity
|
||||||
|
Ethics
|
||||||
|
|
||||||
|
$ colophon --shelf shelf.txt converted/*.md | jq -c '{title, shelved, unshelved}'
|
||||||
|
{"title":"Leviathan","shelved":["Political Science"],"unshelved":["Hobbes, Thomas, 1588–1679. Leviathan"]}
|
||||||
|
{"title":"Christianity and Classical Culture","shelved":["Theology"],"unshelved":[]}
|
||||||
|
```
|
||||||
|
|
||||||
|
`unshelved` is the point of the exercise. A subject quietly dropped reads
|
||||||
|
exactly like a book that printed none, and the difference decides whether you
|
||||||
|
go looking for a heading your shelf is missing.
|
||||||
|
|
||||||
|
**The title page is not evidence on its own.** A scan that begins mid-frontmatter
|
||||||
|
gives up a running head, a copyright line, or the author's name where the title
|
||||||
|
should be. What makes a printed record trustworthy is a second one agreeing,
|
||||||
|
and the ISBN in the same block is how you get one:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
colophon --shelf shelf.txt converted/*.md > printed.ndjson
|
||||||
|
|
||||||
|
jq -r 'select(.isbns != null) | .isbns[0]' printed.ndjson \
|
||||||
|
| idnorm --quiet \
|
||||||
|
| bibfetch --polite you@example.org > register.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
Now two sources that cannot have copied each other have spoken, and you can
|
||||||
|
say which books are settled and which need a person:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
jq -s -r '
|
||||||
|
(.[1] | map({key: .id, value: .}) | from_entries) as $reg
|
||||||
|
| .[0][]
|
||||||
|
| . as $printed
|
||||||
|
| ($reg[$printed.isbns[0] // ""] // {}) as $found
|
||||||
|
| [ $printed.title,
|
||||||
|
$found.title,
|
||||||
|
(if $printed.title and $found.title then "corroborated" else "one source" end)
|
||||||
|
] | @tsv
|
||||||
|
' printed.ndjson register.ndjson
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
The First World War The First World War: A Very Short Introduction corroborated
|
||||||
|
Document5 The Problems of Philosophy corroborated
|
||||||
|
Born Fighting — one source
|
||||||
|
```
|
||||||
|
|
||||||
|
The second row is the case worth the whole recipe: a file named `Document5`
|
||||||
|
whose author reads `-ducati996`, which the book itself and OpenLibrary agree
|
||||||
|
is Russell's *The Problems of Philosophy*. The third is the honest failure —
|
||||||
|
one source said something, nothing confirmed it, and it goes to a human.
|
||||||
|
|
||||||
|
In process, where a pipeline actually lives:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import catena
|
||||||
|
|
||||||
|
shelf = open("shelf.txt").read()
|
||||||
|
printed = catena.read_colophon(markdown, shelf=shelf)
|
||||||
|
|
||||||
|
found = None
|
||||||
|
if printed["isbns"]:
|
||||||
|
found = catena.fetch_identifier(printed["isbns"][0], polite="you@example.org")
|
||||||
|
|
||||||
|
settled = bool(printed.get("title") and found and found.get("title"))
|
||||||
|
```
|
||||||
|
|
||||||
|
`catena.shelve(shelf, subjects)` files headings a catalogue already holds, by
|
||||||
|
the same rules, so a record's existing subjects and its book's printed ones are
|
||||||
|
judged alike.
|
||||||
|
|
||||||
## Asking a model, without this suite calling one
|
## Asking a model, without this suite calling one
|
||||||
|
|
||||||
A tool here may compose a prompt. It does not send one. That keeps every
|
A tool here may compose a prompt. It does not send one. That keeps every
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ NASA broadcast, which is a work of the US government.
|
|||||||
| [`shell/03-search-and-rerank.sh`](shell/03-search-and-rerank.sh) | Passages into pgvector, then a search that asks twice and reconciles |
|
| [`shell/03-search-and-rerank.sh`](shell/03-search-and-rerank.sh) | Passages into pgvector, then a search that asks twice and reconciles |
|
||||||
| [`rust/`](rust/src/main.rs) | The same book walked through in one process, with no pipe anywhere |
|
| [`rust/`](rust/src/main.rs) | The same book walked through in one process, with no pipe anywhere |
|
||||||
| [`python/book_to_citations.py`](python/book_to_citations.py) | A book to rows a database can take |
|
| [`python/book_to_citations.py`](python/book_to_citations.py) | A book to rows a database can take |
|
||||||
|
| [`python/shelve_a_library.py`](python/shelve_a_library.py) | What a book says about itself, shelved and checked against a register |
|
||||||
| [`python/search_and_rerank.py`](python/search_and_rerank.py) | The pgvector search, in the language a pipeline is usually written in |
|
| [`python/search_and_rerank.py`](python/search_and_rerank.py) | The pgvector search, in the language a pipeline is usually written in |
|
||||||
| [`typescript/src/episode-to-chapters.ts`](typescript/src/episode-to-chapters.ts) | Captions into chapter marks and deep links for a player |
|
| [`typescript/src/episode-to-chapters.ts`](typescript/src/episode-to-chapters.ts) | Captions into chapter marks and deep links for a player |
|
||||||
| [`typescript/src/search-and-rerank.ts`](typescript/src/search-and-rerank.ts) | Fusing two rankings app-side, and what re-ranking means |
|
| [`typescript/src/search-and-rerank.ts`](typescript/src/search-and-rerank.ts) | Fusing two rankings app-side, and what re-ranking means |
|
||||||
@@ -83,6 +84,20 @@ python examples/python/book_to_citations.py
|
|||||||
pip install catena-corpus psycopg
|
pip install catena-corpus psycopg
|
||||||
DATABASE_URL=postgresql://user@host/db \
|
DATABASE_URL=postgresql://user@host/db \
|
||||||
python examples/python/search_and_rerank.py "why did the empire decline"
|
python examples/python/search_and_rerank.py "why did the empire decline"
|
||||||
|
|
||||||
|
`shelve_a_library.py` is the metadata half: it reads the cataloguing block a
|
||||||
|
book prints in its own front matter, files those subjects onto a closed list
|
||||||
|
you supply, and asks the register that assigned the ISBN whether it agrees.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python examples/python/shelve_a_library.py converted/*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Neither source is worth acting on alone — a scan that starts mid-frontmatter
|
||||||
|
gives up a running head where the title should be, and an ISBN a catalogue has
|
||||||
|
been carrying wrongly returns a confidently wrong book. The example prints
|
||||||
|
`corroborated` only when both said the same thing, and the disagreements are
|
||||||
|
the interesting output.
|
||||||
```
|
```
|
||||||
|
|
||||||
## TypeScript
|
## TypeScript
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""What a book says about itself, checked against what a register says back.
|
||||||
|
|
||||||
|
A catalogue's subjects and titles come from wherever its files came from. Two
|
||||||
|
things can correct that without a model: the cataloguing block the book prints
|
||||||
|
in its own front matter, and the register that assigned its ISBN. Neither is
|
||||||
|
trustworthy alone — a scan that starts mid-frontmatter yields a running head
|
||||||
|
where the title should be, and an ISBN a catalogue has been carrying wrongly
|
||||||
|
returns a confidently wrong book. Together they are worth acting on.
|
||||||
|
|
||||||
|
python examples/python/shelve_a_library.py converted/*.md
|
||||||
|
|
||||||
|
With no arguments it reads a book from Project Gutenberg, which prints no
|
||||||
|
cataloguing block at all — the honest common case, and worth seeing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
import catena
|
||||||
|
|
||||||
|
# The closed list this library is browsed by. `Heading = term, term` gives the
|
||||||
|
# words that file a book under it; a heading on its own matches its own name.
|
||||||
|
SHELF = """
|
||||||
|
Philosophy = philosophy, philosophical
|
||||||
|
Ancient Philosophy = ancient philosophy, plato, aristotle
|
||||||
|
Political Science = political science, politics, government
|
||||||
|
Theology = theology, christianity, religion
|
||||||
|
Church History = church history, ecclesiastical history
|
||||||
|
Byzantine History = byzantium, byzantine, constantinople
|
||||||
|
History = history, historiography
|
||||||
|
Literature = literature, fiction, poetry
|
||||||
|
Psychology = psychology
|
||||||
|
Ethics
|
||||||
|
Logic
|
||||||
|
"""
|
||||||
|
|
||||||
|
GUTENBERG = "https://www.gutenberg.org/cache/epub/5827/pg5827.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def sample(into: pathlib.Path) -> str:
|
||||||
|
"""Locke's *Essay*, which Gutenberg strips the front matter from."""
|
||||||
|
book = into / "locke.txt"
|
||||||
|
if not book.exists():
|
||||||
|
into.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f"fetching {GUTENBERG}", file=sys.stderr)
|
||||||
|
with urllib.request.urlopen(GUTENBERG, timeout=120) as reply: # noqa: S310
|
||||||
|
book.write_bytes(reply.read())
|
||||||
|
return book.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def same(a: str, b: str) -> bool:
|
||||||
|
"""Whether two printings of one title are the same title.
|
||||||
|
|
||||||
|
Compared on letters and digits alone. A register and a title page differ
|
||||||
|
over case, over a curly apostrophe against a straight one, and over
|
||||||
|
whether the subtitle is part of the title — none of which is a
|
||||||
|
disagreement, and all of which a plain string comparison calls one.
|
||||||
|
"""
|
||||||
|
fold = lambda s: re.sub(r"[^a-z0-9]+", "", (s or "").lower())
|
||||||
|
a, b = fold(a), fold(b)
|
||||||
|
return bool(a) and bool(b) and (a in b or b in a)
|
||||||
|
|
||||||
|
|
||||||
|
def look(text: str, name: str) -> None:
|
||||||
|
printed = catena.read_colophon(text, shelf=SHELF)
|
||||||
|
|
||||||
|
title = printed.get("title")
|
||||||
|
isbns = printed.get("isbns") or []
|
||||||
|
print(f"\n{name}")
|
||||||
|
print(f" printed title {title or '— (no title page in these pages)'}")
|
||||||
|
if printed.get("authors"):
|
||||||
|
print(f" printed authors {', '.join(printed['authors'])}")
|
||||||
|
if printed.get("shelved"):
|
||||||
|
print(f" shelved under {', '.join(printed['shelved'])}")
|
||||||
|
for raw in printed.get("unshelved", []):
|
||||||
|
# Not a failure: a heading the shelf has no place for is how you find
|
||||||
|
# out the shelf is missing one.
|
||||||
|
print(f" no shelf for {raw}")
|
||||||
|
if not printed.get("subjects"):
|
||||||
|
print(" subjects — (no cataloguing block printed)")
|
||||||
|
|
||||||
|
if not isbns:
|
||||||
|
print(" register — (nothing to look up; one source only)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# The only call here that opens a socket, and it blocks.
|
||||||
|
found = catena.fetch_identifier(isbns[0], polite="you@example.org")
|
||||||
|
if not found or not found.get("found"):
|
||||||
|
print(f" register {isbns[0]} is unknown to every register asked")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f" register says {found['title']} / {', '.join(found.get('authors') or [])}")
|
||||||
|
agrees = bool(title) and same(title, found["title"])
|
||||||
|
print(f" verdict {'corroborated' if agrees else 'two sources disagree — ask a person'}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
paths = [pathlib.Path(p) for p in sys.argv[1:]]
|
||||||
|
if not paths:
|
||||||
|
look(sample(pathlib.Path("/tmp/catena-example")), "Locke, from Gutenberg")
|
||||||
|
return 0
|
||||||
|
for path in paths:
|
||||||
|
look(path.read_text(encoding="utf-8", errors="replace"), path.name)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user