mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 11:17:13 +00:00
516 lines
17 KiB
Rust
516 lines
17 KiB
Rust
//! What is wrong with a record, and how badly.
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use fancy_regex::Regex as Boundaried;
|
|
use regex::Regex;
|
|
use textkit::pattern::{SPACE, WORD_END, WORD_START};
|
|
|
|
fn pattern(source: &str) -> Regex {
|
|
Regex::new(source).expect("a valid pattern")
|
|
}
|
|
|
|
fn bounded(source: &str) -> Boundaried {
|
|
Boundaried::new(source).expect("a valid pattern")
|
|
}
|
|
|
|
/// A bare filename sitting in the title field.
|
|
static FILENAME_TITLE: LazyLock<Regex> = LazyLock::new(|| {
|
|
pattern(&format!(
|
|
r"(?i)\.(png|jpe?g|pdf|epub|mobi|azw3|azw|txt|wpd|docx?|rtf|tex|qxd|indd|pm\d){SPACE}*$"
|
|
))
|
|
});
|
|
|
|
/// A title that names the tool that made the file rather than the work.
|
|
///
|
|
/// Authoring and desktop-publishing programs stamp their scratch filename as
|
|
/// the PDF title; scanners and converters name themselves. The last of these
|
|
/// is a Turkish locale's "full page fax print" — the same driver, localised.
|
|
static JUNK_TITLE: LazyLock<Boundaried> = LazyLock::new(|| {
|
|
bounded(&format!(
|
|
r"(?i)microsoft word|z-?lib|libgen|\(z-lib|annas?-?archive|{WORD_START}ocr{WORD_END}|nota bene|pagemaker|quarkxpress|adobe indesign|acrobat distiller|word ?pro|wordperfect|{WORD_START}dvips{WORD_END}|{WORD_START}ltx{WORD_END}|{WORD_START}pdftex{WORD_END}|djvu document|untitled document|full page fax print|tam sayfa faks"
|
|
))
|
|
});
|
|
|
|
/// A path sitting in the title field. Distinct from a filename, which is a
|
|
/// bare name ending in an extension.
|
|
static PATH_TITLE: LazyLock<Regex> =
|
|
LazyLock::new(|| pattern(r"(^[A-Za-z]:[\\/])|(\\\\[^\\]+\\)|(^/(?:home|users|mnt|tmp|var)/)"));
|
|
|
|
/// A title that is a placeholder rather than a name.
|
|
///
|
|
/// Anchored and whole-string: a real book called "The Document" or "Test"
|
|
/// must not be swept up, so these only match when the entire field is the
|
|
/// placeholder.
|
|
static PLACEHOLDER_TITLE: LazyLock<Regex> = LazyLock::new(|| {
|
|
pattern(
|
|
r"(?i)^\W*(?:some[\s_-]*title|no[\s_-]*title|untitled|unnamed|title|document|documento|test|sample|default|placeholder|tbd|todo|draft|temp|new[\s_-]*document|book\d*|file\d*|scan\d*|doc\d*|n/?a|null|none|x{3,}|unknown)\W*$",
|
|
)
|
|
});
|
|
|
|
static PLACEHOLDER_AUTHOR: LazyLock<Regex> = LazyLock::new(|| {
|
|
pattern(
|
|
r"(?i)^\W*(?:some[\s_-]*author|no[\s_-]*author|unnamed|author|default|test|sample|placeholder|tbd|todo|n/?a|null|none|x{3,}|administrator|user|owner|admin)\W*$",
|
|
)
|
|
});
|
|
|
|
/// An author field that opens with a bare three or four digit number is a
|
|
/// publication year or a series index in the wrong column, never a person.
|
|
/// Anchored, so a real name containing a number is untouched.
|
|
static NUMERIC_AUTHOR: LazyLock<Regex> =
|
|
LazyLock::new(|| pattern(&format!(r"^{SPACE}*\d{{3,4}}{SPACE}*(?:$|[{SPACE}.\-–—])")));
|
|
|
|
/// A possessive apostrophe that a filename or an OCR pass stripped:
|
|
/// "Lossky s Argument".
|
|
static MISSING_APOSTROPHE: LazyLock<Boundaried> =
|
|
LazyLock::new(|| bounded(&format!(r"{WORD_START}[A-Za-z]{{2,}} s [A-Z]")));
|
|
|
|
/// Punctuation that a title has and a name does not.
|
|
static TITLE_PUNCTUATION: LazyLock<Regex> = LazyLock::new(|| pattern(r"[!?:]"));
|
|
|
|
/// A surname-first comma or a run of initials.
|
|
static INITIALS: LazyLock<Boundaried> =
|
|
LazyLock::new(|| bounded(&format!(r"{WORD_START}[A-Z]\.{SPACE}*[A-Z]?\.?")));
|
|
|
|
/// Function words that essentially never appear in a personal name and are
|
|
/// everywhere in titles.
|
|
static TITLE_WORDS: LazyLock<Boundaried> = LazyLock::new(|| {
|
|
bounded(&format!(
|
|
r"(?i){WORD_START}(of|the|and|in|on|for|to|from|with|a|an|about|between|through|introduction|dictionary|handbook|companion|history|study|essays|conception|objections|architecture|philosophy|theory){WORD_END}"
|
|
))
|
|
});
|
|
|
|
/// How authors are joined when several share a book.
|
|
static AUTHOR_SEPARATOR: LazyLock<Regex> =
|
|
LazyLock::new(|| pattern(&format!(r"{SPACE}+&{SPACE}+|{SPACE}*;{SPACE}*")));
|
|
|
|
/// Publisher and imprint names that landed in the author field.
|
|
///
|
|
/// Matched whole — "Oxford University Press" as an author is wrong, but
|
|
/// "Oxford, Michael" is a person. Kept lowercase and compared against the
|
|
/// normalised field.
|
|
const PUBLISHER_AUTHORS: &[&str] = &[
|
|
"baker academic",
|
|
"blackwell",
|
|
"bloomsbury",
|
|
"brill",
|
|
"cambridge",
|
|
"cambridge university press",
|
|
"continuum",
|
|
"cornell university press",
|
|
"crossway",
|
|
"de gruyter",
|
|
"eerdmans",
|
|
"elsevier",
|
|
"fortress press",
|
|
"harpercollins",
|
|
"harvard university press",
|
|
"ignatius press",
|
|
"intervarsity press",
|
|
"macmillan",
|
|
"mit press",
|
|
"oxford",
|
|
"oxford university press",
|
|
"palgrave",
|
|
"palgrave macmillan",
|
|
"penguin",
|
|
"penguin books",
|
|
"princeton university press",
|
|
"random house",
|
|
"routledge",
|
|
"sage",
|
|
"simon schuster",
|
|
"springer",
|
|
"state university of new york press",
|
|
"suny",
|
|
"suny press",
|
|
"t t clark",
|
|
"university of chicago press",
|
|
"wiley blackwell",
|
|
"wipf stock",
|
|
"yale university press",
|
|
"zondervan",
|
|
];
|
|
|
|
/// Author fields that say there is no author.
|
|
const UNKNOWN_AUTHORS: &[&str] = &["", "anonymous", "n/a", "unknown", "unknown author"];
|
|
|
|
/// Word endings that mark a personal name.
|
|
const PERSON_SUFFIXES: &[&str] = &["ed", "eds", "ii", "iii", "iv", "jr", "md", "phd", "sr"];
|
|
|
|
/// What each fault is worth when the worst records are wanted first.
|
|
const WEIGHTS: &[(&str, u32)] = &[
|
|
("allcaps_title", 2),
|
|
("author_last_first", 1),
|
|
("author_role_suffix", 2),
|
|
("edited_volume", 1),
|
|
("filename_title", 3),
|
|
("junk_title", 3),
|
|
("lowercase_title", 2),
|
|
("missing_apostrophe", 2),
|
|
("numeric_author", 4),
|
|
("path_title", 4),
|
|
("placeholder_author", 4),
|
|
("placeholder_title", 4),
|
|
("possible_swap", 2),
|
|
("publisher_as_author", 3),
|
|
("sentence_case_title", 1),
|
|
("title_in_author", 3),
|
|
("underscore_title", 1),
|
|
("unknown_author", 3),
|
|
];
|
|
|
|
/// Everything wrong with a record, named.
|
|
///
|
|
/// `missing_isbn` is reported but is not on its own a reason to do anything:
|
|
/// nearly every book lacks one.
|
|
pub fn problems(title: Option<&str>, author: Option<&str>, isbn: Option<&str>) -> Vec<String> {
|
|
let title = title.unwrap_or("").trim_matches(textkit::is_space);
|
|
let author = author.unwrap_or("").trim_matches(textkit::is_space);
|
|
let mut reasons = Vec::new();
|
|
|
|
if UNKNOWN_AUTHORS
|
|
.binary_search(&author.to_lowercase().as_str())
|
|
.is_ok()
|
|
{
|
|
reasons.push("unknown_author");
|
|
}
|
|
if PLACEHOLDER_TITLE.is_match(title) {
|
|
reasons.push("placeholder_title");
|
|
}
|
|
if PLACEHOLDER_AUTHOR.is_match(author) {
|
|
reasons.push("placeholder_author");
|
|
}
|
|
if PATH_TITLE.is_match(title) {
|
|
reasons.push("path_title");
|
|
}
|
|
if FILENAME_TITLE.is_match(title) {
|
|
reasons.push("filename_title");
|
|
}
|
|
if JUNK_TITLE.is_match(title).unwrap_or(false) {
|
|
reasons.push("junk_title");
|
|
}
|
|
if is_publisher(author) {
|
|
reasons.push("publisher_as_author");
|
|
}
|
|
if NUMERIC_AUTHOR
|
|
.find(author)
|
|
.is_some_and(|at| at.start() == 0)
|
|
{
|
|
reasons.push("numeric_author");
|
|
}
|
|
if title.contains('_') {
|
|
reasons.push("underscore_title");
|
|
}
|
|
if !title.is_empty() && title == title.to_uppercase() && title.contains(' ') {
|
|
reasons.push("allcaps_title");
|
|
}
|
|
// A filename stub: "heidegger areopagite". Distinct from sentence case,
|
|
// which is a much weaker signal.
|
|
if !title.is_empty()
|
|
&& title == title.to_lowercase()
|
|
&& title.chars().any(|c| c.is_lowercase())
|
|
&& (title.contains(' ') || title.chars().count() >= 12)
|
|
{
|
|
reasons.push("lowercase_title");
|
|
}
|
|
if MISSING_APOSTROPHE.is_match(title).unwrap_or(false) {
|
|
reasons.push("missing_apostrophe");
|
|
}
|
|
// Punctuation in the author field, or a long author against a short
|
|
// title. Gated on the author not looking like a person, because several
|
|
// authors joined together are many words against what may be a one-word
|
|
// title, and that is not a swap.
|
|
if !looks_like_person(author)
|
|
&& (TITLE_PUNCTUATION.is_match(author)
|
|
|| (author.split_whitespace().count() >= 5
|
|
&& (1..=3).contains(&title.split_whitespace().count())))
|
|
{
|
|
reasons.push("possible_swap");
|
|
}
|
|
if title_in_author(Some(title), Some(author)) {
|
|
reasons.push("title_in_author");
|
|
}
|
|
if isbn
|
|
.unwrap_or("")
|
|
.trim_matches(textkit::is_space)
|
|
.is_empty()
|
|
{
|
|
reasons.push("missing_isbn");
|
|
}
|
|
|
|
reasons.into_iter().map(str::to_string).collect()
|
|
}
|
|
|
|
/// How much attention a set of faults deserves.
|
|
pub fn severity(reasons: &[String]) -> u32 {
|
|
reasons
|
|
.iter()
|
|
.filter_map(|reason| {
|
|
WEIGHTS
|
|
.binary_search_by_key(&reason.as_str(), |(name, _)| name)
|
|
.ok()
|
|
.map(|at| WEIGHTS[at].1)
|
|
})
|
|
.sum()
|
|
}
|
|
|
|
/// Whether anything here needs doing. A missing ISBN alone does not.
|
|
pub fn worth_fixing(reasons: &[String]) -> bool {
|
|
reasons.iter().any(|reason| reason != "missing_isbn")
|
|
}
|
|
|
|
/// Whether the title field is a placeholder and the author field is holding
|
|
/// the real title.
|
|
///
|
|
/// Deliberately conservative: it must be both a placeholder title and an
|
|
/// author that does not read as a person, because what this drives — looking
|
|
/// the book up by the author text as a title — is wasted work otherwise.
|
|
pub fn title_in_author(title: Option<&str>, author: Option<&str>) -> bool {
|
|
let title = title.unwrap_or("").trim_matches(textkit::is_space);
|
|
let author = author.unwrap_or("").trim_matches(textkit::is_space);
|
|
|
|
if author.is_empty() || !(PLACEHOLDER_TITLE.is_match(title) || PATH_TITLE.is_match(title)) {
|
|
return false;
|
|
}
|
|
// A placeholder author is not a stray title. Treating it as one sends a
|
|
// junk string to a catalogue as a title query, which is the spurious
|
|
// match every other part of this guards against.
|
|
if PLACEHOLDER_AUTHOR.is_match(author) {
|
|
return false;
|
|
}
|
|
if is_publisher(author)
|
|
|| UNKNOWN_AUTHORS
|
|
.binary_search(&author.to_lowercase().as_str())
|
|
.is_ok()
|
|
{
|
|
return false;
|
|
}
|
|
!looks_like_person(author)
|
|
}
|
|
|
|
/// Whether a field reads as a human name.
|
|
///
|
|
/// Multi-author strings are judged one author at a time. A catalogue joins
|
|
/// authors with an ampersand, so two people arrive as one seven-word field —
|
|
/// judged whole it fails every test and reads as a title, which is how a
|
|
/// naive version of this flagged hundreds of perfectly good records.
|
|
pub fn looks_like_person(author: &str) -> bool {
|
|
let author = author.trim_matches(textkit::is_space);
|
|
if author.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let parts: Vec<&str> = AUTHOR_SEPARATOR
|
|
.split(author)
|
|
.map(|part| part.trim_matches(textkit::is_space))
|
|
.filter(|part| !part.is_empty())
|
|
.collect();
|
|
if parts.len() > 1 {
|
|
return parts.iter().all(|part| looks_like_person(part));
|
|
}
|
|
|
|
let words: Vec<&str> = author.split_whitespace().collect();
|
|
if author.contains(',') && words.len() <= 5 {
|
|
return true; // "Surname, Given"
|
|
}
|
|
if INITIALS.is_match(author).unwrap_or(false) && words.len() <= 5 {
|
|
return true;
|
|
}
|
|
if words.len() > 5 {
|
|
return false;
|
|
}
|
|
if TITLE_WORDS.is_match(author).unwrap_or(false) {
|
|
return false;
|
|
}
|
|
if words
|
|
.iter()
|
|
.any(|word| PERSON_SUFFIXES.contains(&word.to_lowercase().trim_matches('.')))
|
|
{
|
|
return true;
|
|
}
|
|
// A bare single word is a mononym far more often than a stray title —
|
|
// Plato, Homer and Josephus are all over a philosophy library, and a
|
|
// one-word "title" recovered from an author field is nearly never right.
|
|
(1..=4).contains(&words.len())
|
|
}
|
|
|
|
/// A catalogue string reduced to the letters and digits in it.
|
|
pub fn normalise(text: &str) -> String {
|
|
let mut out = String::with_capacity(text.len());
|
|
let mut space = false;
|
|
for c in text.to_lowercase().chars() {
|
|
if c.is_ascii_alphanumeric() {
|
|
if space && !out.is_empty() {
|
|
out.push(' ');
|
|
}
|
|
space = false;
|
|
out.push(c);
|
|
} else {
|
|
space = true;
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn is_publisher(author: &str) -> bool {
|
|
PUBLISHER_AUTHORS
|
|
.binary_search(&normalise(author).as_str())
|
|
.is_ok()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn problems_of(title: &str, author: &str) -> Vec<String> {
|
|
problems(Some(title), Some(author), Some("9780198237907"))
|
|
}
|
|
|
|
#[test]
|
|
fn the_tables_are_sorted_so_they_can_be_searched() {
|
|
assert!(PUBLISHER_AUTHORS.windows(2).all(|pair| pair[0] < pair[1]));
|
|
assert!(UNKNOWN_AUTHORS.windows(2).all(|pair| pair[0] < pair[1]));
|
|
assert!(PERSON_SUFFIXES.windows(2).all(|pair| pair[0] < pair[1]));
|
|
assert!(WEIGHTS.windows(2).all(|pair| pair[0].0 < pair[1].0));
|
|
}
|
|
|
|
#[test]
|
|
fn a_good_record_has_nothing_wrong_with_it() {
|
|
assert!(problems_of("The Human Condition", "Arendt, Hannah").is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_missing_isbn_is_reported_but_is_not_worth_fixing() {
|
|
let reasons = problems(Some("The Human Condition"), Some("Arendt, Hannah"), None);
|
|
assert_eq!(reasons, ["missing_isbn"]);
|
|
assert!(!worth_fixing(&reasons));
|
|
}
|
|
|
|
#[test]
|
|
fn a_placeholder_title_is_found() {
|
|
for title in ["Untitled", "untitled", "no title", "Document", "xxx", "N/A"] {
|
|
assert!(
|
|
problems_of(title, "Arendt, Hannah").contains(&"placeholder_title".to_string()),
|
|
"{title}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_real_title_that_resembles_a_placeholder_is_left_alone() {
|
|
for title in ["The Document", "A Test of Faith", "Untitled Symphony"] {
|
|
assert!(
|
|
!problems_of(title, "Arendt, Hannah").contains(&"placeholder_title".to_string()),
|
|
"{title}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_filename_and_a_path_in_the_title_are_told_apart() {
|
|
assert!(problems_of("chapter1.pdf", "x").contains(&"filename_title".to_string()));
|
|
assert!(problems_of("A:\\pwecent2005.wpd", "x").contains(&"path_title".to_string()));
|
|
assert!(problems_of("/home/matt/book", "x").contains(&"path_title".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_tool_name_in_the_title_is_junk() {
|
|
for title in [
|
|
"Microsoft Word - chapter3",
|
|
"Book (z-lib.org)",
|
|
"DjVu Document",
|
|
"Full Page Fax Print",
|
|
"scan OCR output",
|
|
] {
|
|
assert!(
|
|
problems_of(title, "x").contains(&"junk_title".to_string()),
|
|
"{title}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_word_that_merely_contains_a_tool_name_is_not_junk() {
|
|
assert!(!problems_of("Procrastination", "x").contains(&"junk_title".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_publisher_in_the_author_field_is_found() {
|
|
assert!(
|
|
problems_of("A Book", "Oxford University Press")
|
|
.contains(&"publisher_as_author".to_string())
|
|
);
|
|
assert!(
|
|
!problems_of("A Book", "Oxford, Michael").contains(&"publisher_as_author".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn shouting_and_whispering_are_both_noticed() {
|
|
assert!(problems_of("THE HUMAN CONDITION", "x").contains(&"allcaps_title".to_string()));
|
|
assert!(problems_of("heidegger areopagite", "x").contains(&"lowercase_title".to_string()));
|
|
assert!(!problems_of("MIT", "x").contains(&"allcaps_title".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn several_authors_are_not_a_swapped_title() {
|
|
let reasons = problems_of("Rama", "Arthur C. Clarke & Gentry Lee");
|
|
assert!(
|
|
!reasons.contains(&"possible_swap".to_string()),
|
|
"{reasons:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_title_in_the_author_field_is_found() {
|
|
assert!(title_in_author(Some("Untitled"), Some("Arguing About War")));
|
|
assert!(!title_in_author(Some("Untitled"), Some("Arendt, Hannah")));
|
|
assert!(!title_in_author(
|
|
Some("The Human Condition"),
|
|
Some("Arguing About War")
|
|
));
|
|
assert!(!title_in_author(Some("Untitled"), Some("Default")));
|
|
assert!(!title_in_author(Some("Untitled"), Some("")));
|
|
}
|
|
|
|
#[test]
|
|
fn people_and_titles_are_told_apart() {
|
|
for person in [
|
|
"Arendt, Hannah",
|
|
"C. S. Lewis",
|
|
"Plato",
|
|
"Hannah Arendt",
|
|
"C. S. Lewis & Madeleine L'Engle",
|
|
"Smith Jr",
|
|
] {
|
|
assert!(looks_like_person(person), "{person}");
|
|
}
|
|
for title in [
|
|
"An Ethnohistorical Dictionary China",
|
|
"Kierkegaard's Conception of God",
|
|
"The Oxford Handbook of Something Or Other",
|
|
] {
|
|
assert!(!looks_like_person(title), "{title}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn severity_ranks_the_worst_first() {
|
|
let bad = problems_of("Untitled", "Arguing About War");
|
|
let mild = problems_of("Some_Book", "Arendt, Hannah");
|
|
assert!(severity(&bad) > severity(&mild), "{bad:?} vs {mild:?}");
|
|
assert_eq!(severity(&[]), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn normalising_keeps_only_what_identifies() {
|
|
assert_eq!(
|
|
normalise("Oxford University Press!"),
|
|
"oxford university press"
|
|
);
|
|
assert_eq!(normalise(" T&T Clark "), "t t clark");
|
|
assert_eq!(normalise(""), "");
|
|
}
|
|
}
|