mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
seriesof: the series a book belongs to, and which volume this is
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "seriesof"
|
||||
description = "The series a book belongs to, and which volume of it this is."
|
||||
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
|
||||
fancy-regex.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
textkit.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,161 @@
|
||||
# seriesof
|
||||
|
||||
The series a book belongs to, and which volume of it this is.
|
||||
|
||||
Part of [catena](../../README.md).
|
||||
|
||||
## What it does
|
||||
|
||||
Reads a series and a volume off a title, and says nothing about the books it
|
||||
cannot place.
|
||||
|
||||
A shelf groups by series and volume, and most catalogues have neither set on
|
||||
most books. What that looks like from the reading chair: four records all
|
||||
reading "A History of Philosophy" with nothing to say which volume any of them
|
||||
is, and five volumes of one work sorting as five unrelated books.
|
||||
|
||||
## When you would use it
|
||||
|
||||
- **Before importing a shelf** into anything that browses by series.
|
||||
- **Auditing a catalogue** that was built from filenames, where the series was
|
||||
in the title all along and nobody read it.
|
||||
- **Finding the volumes of a work you already have**, which is a query you
|
||||
cannot ask until the series field exists.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
cargo install --git https://git.pumpin.online/funkpower/catena seriesof
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
$ seriesof catalogue.ndjson | jq -r '"\(.series)\t#\(.volume // "—")\t\(.method)"'
|
||||
The Philokalia #3 volume
|
||||
A History of Philosophy #4 volume
|
||||
The Art of Computer Programming #3 volume
|
||||
Cambridge Companions #— publisher
|
||||
Very Short Introductions #— publisher
|
||||
Blackwell Companions #— publisher
|
||||
Raven Son #1 volume
|
||||
```
|
||||
|
||||
The whole record, for a title that carries a volume:
|
||||
|
||||
```
|
||||
$ seriesof catalogue.ndjson | head -1
|
||||
{"v":1,"title":"The Philokalia Vol 3","author":"Palmer","series":"The Philokalia","volume":3,"method":"volume"}
|
||||
```
|
||||
|
||||
And for publisher shorthand, which proposes a title as well:
|
||||
|
||||
```
|
||||
$ seriesof catalogue.ndjson | jq -c 'select(.method == "publisher") | {series, proposed_title}' | head -2
|
||||
{"series":"Cambridge Companions","proposed_title":"The Cambridge Companion to Aquinas"}
|
||||
{"series":"Very Short Introductions","proposed_title":"Logic: A Very Short Introduction"}
|
||||
```
|
||||
|
||||
Plain titles, for a directory rather than a catalogue:
|
||||
|
||||
```
|
||||
$ ls *.epub | sed 's/\.epub$//' | seriesof --titles
|
||||
{"v":1,"title":"The Philokalia Vol 3","series":"The Philokalia","volume":3,"method":"volume"}
|
||||
```
|
||||
|
||||
Nothing to propose is an answer:
|
||||
|
||||
```
|
||||
$ printf '{"title":"Being and Time"}\n' | seriesof
|
||||
$ echo $?
|
||||
1
|
||||
```
|
||||
|
||||
Applying only what you believe:
|
||||
|
||||
```sh
|
||||
seriesof catalogue.ndjson \
|
||||
| jq -r 'select(.volume) | "UPDATE book SET series = \(.series|@json), series_index = \(.volume) WHERE title = \(.title|@json);"' \
|
||||
| psql
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Effect |
|
||||
|---|---|
|
||||
| `--titles` | Read plain titles, one per line, rather than records |
|
||||
| `--man` | Write this page as a manual page, in roff |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|---|---|
|
||||
| `0` | Something was proposed |
|
||||
| `1` | Nothing was |
|
||||
| `64` | The command line was wrong |
|
||||
| `65` | The input was not records |
|
||||
| `74` | The file could not be read |
|
||||
|
||||
## How it decides
|
||||
|
||||
Two shapes are recoverable, and they are not the same kind of fact.
|
||||
|
||||
### A volume marker is a position
|
||||
|
||||
"The Philokalia Vol 3", "Collected Works, Volume 9", "(Raven Son Book 1)".
|
||||
The unit word is required — `Vol`, `Volume`, `Book`, `Part`, `Pt`, `Tome`,
|
||||
`Bk` — because a bare trailing number is far more often a year, an edition or
|
||||
noise. Arabic and roman numerals both count, in any script, up to two hundred.
|
||||
|
||||
The series is **what comes before the marker**. Whatever follows is that
|
||||
volume's own subtitle: "The Art of Computer Programming, Volume 3: Sorting and
|
||||
Searching" is volume 3 *of* the former, and gluing the halves back together
|
||||
gives a title nobody published.
|
||||
|
||||
Except where the convention puts the series inside a bracket — "The Song of
|
||||
the Sirin (Raven Son Book 1)" is book one of *Raven Son*, not of "The Song of
|
||||
the Sirin (Raven Son".
|
||||
|
||||
A marker with nothing usable before it is left alone. "Vol. 2. PLATO,
|
||||
EPICTETUS, MARCUS AURELIUS" is a volume of something whose name is not in the
|
||||
field at all, and filing it under its own contents is worse than not filing
|
||||
it.
|
||||
|
||||
### Publisher shorthand is only membership
|
||||
|
||||
A title of "Aquinas (Companion)" against an author of "Cambridge" means the
|
||||
publisher landed in the author column and the collection name was flattened
|
||||
into a parenthetical. The work is *The Cambridge Companion to Aquinas*.
|
||||
|
||||
These get a series and **no number**, deliberately. Cambridge Companions are
|
||||
not numbered, nobody knows what "#3" would mean, and cataloguing software
|
||||
defaults a missing index to 1 — so filing them as a numbered series makes
|
||||
every one display as volume one, asserting an order the data does not have.
|
||||
|
||||
A title is only reconstructed where it reliably can be. Cambridge and
|
||||
Routledge front theirs with the imprint; Blackwell does not — its Companions
|
||||
to Philosophy are "A Companion to Nietzsche", while a few of its religion
|
||||
titles genuinely are "The Blackwell Companion to …". The shorthand cannot tell
|
||||
which, so Blackwell gets its series and no title claim.
|
||||
|
||||
Recognised: Cambridge, Oxford, Blackwell, Wiley Blackwell, Routledge, SUNY,
|
||||
Palgrave and Springer, across companions, concise companions, handbooks,
|
||||
guidebooks, Very Short Introductions, Contemporary Debates, contemporary
|
||||
introductions, anthologies and studies.
|
||||
|
||||
## As a library
|
||||
|
||||
```rust
|
||||
let found = seriesof::series_of("The Philokalia Vol 3", None).expect("a series");
|
||||
assert_eq!(found.series, "The Philokalia");
|
||||
assert_eq!(found.volume, Some(3));
|
||||
```
|
||||
|
||||
`parse_volume` and `publisher_series` answer each half on its own, and
|
||||
`roman_or_int` reads a numeral in any script.
|
||||
|
||||
## See also
|
||||
|
||||
- [`bibclean`](../bibclean/README.md) — the rest of what a catalogue record
|
||||
needs before it is worth keeping.
|
||||
- [`booksniff`](../booksniff/README.md) — what the file actually is.
|
||||
@@ -0,0 +1,147 @@
|
||||
//! The series a book belongs to, and which volume of it this is.
|
||||
//!
|
||||
//! A shelf groups by series and volume, and most catalogues have neither set
|
||||
//! on most books. The consequence is visible rather than theoretical: four
|
||||
//! records all reading "A History of Philosophy" with nothing to say which
|
||||
//! volume any of them is, and five volumes of one work sorting as five
|
||||
//! unrelated books.
|
||||
//!
|
||||
//! Two shapes are recoverable from text the catalogue already holds, and they
|
||||
//! are not the same kind of fact.
|
||||
//!
|
||||
//! A **volume marker** in the title — "The Philokalia Vol 3", "Collected
|
||||
//! Works, Volume 9", "(Raven Son Book 1)" — is a *position*, and gives both a
|
||||
//! series and a number.
|
||||
//!
|
||||
//! **Publisher shorthand** — a title of "Aquinas (Companion)" against an
|
||||
//! author of "Cambridge" — is only *membership*. Cambridge Companions are not
|
||||
//! numbered, so filing one under a numbered series makes every one of them
|
||||
//! display as volume one, asserting an order the data does not have. Those
|
||||
//! get a series and no number.
|
||||
|
||||
pub mod publisher;
|
||||
pub mod volume;
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use publisher::publisher_series;
|
||||
pub use volume::{parse_volume, roman_or_int};
|
||||
|
||||
/// What a title turned out to say about its series.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct Proposal {
|
||||
/// Record version.
|
||||
#[serde(default = "version")]
|
||||
pub v: u32,
|
||||
/// The title it was read from.
|
||||
pub title: String,
|
||||
/// The author it was read against, where one was given.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
/// The series the book belongs to.
|
||||
pub series: String,
|
||||
/// Which volume, where the title said. Absent for a series that is
|
||||
/// membership rather than position.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub volume: Option<u32>,
|
||||
/// A title reconstructed from the shorthand, where one reliably can be.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub proposed_title: Option<String>,
|
||||
/// Which of the two shapes this was.
|
||||
pub method: Method,
|
||||
}
|
||||
|
||||
/// How a series was arrived at.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Method {
|
||||
/// A volume marker in the title: a position within a series.
|
||||
Volume,
|
||||
/// Publisher shorthand in a parenthetical: membership, not position.
|
||||
Publisher,
|
||||
}
|
||||
|
||||
fn version() -> u32 {
|
||||
corpus_core::V
|
||||
}
|
||||
|
||||
impl corpus_core::Record for Proposal {
|
||||
fn version(&self) -> u32 {
|
||||
self.v
|
||||
}
|
||||
}
|
||||
|
||||
/// What a title and author say about the series, if anything.
|
||||
///
|
||||
/// The volume marker is looked for first: it carries a number, and a number
|
||||
/// is worth more than membership.
|
||||
pub fn series_of(title: &str, author: Option<&str>) -> Option<Proposal> {
|
||||
if let Some((series, volume)) = parse_volume(title) {
|
||||
return Some(Proposal {
|
||||
v: corpus_core::V,
|
||||
title: title.to_string(),
|
||||
author: author.map(str::to_string),
|
||||
series,
|
||||
volume: Some(volume),
|
||||
proposed_title: None,
|
||||
method: Method::Volume,
|
||||
});
|
||||
}
|
||||
|
||||
let (proposed_title, series) = publisher_series(title, author?)?;
|
||||
Some(Proposal {
|
||||
v: corpus_core::V,
|
||||
title: title.to_string(),
|
||||
author: author.map(str::to_string),
|
||||
series,
|
||||
volume: None,
|
||||
proposed_title,
|
||||
method: Method::Publisher,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_volume_marker_gives_a_series_and_a_number() {
|
||||
let found = series_of("The Philokalia Vol 3", None).expect("a series");
|
||||
assert_eq!(found.series, "The Philokalia");
|
||||
assert_eq!(found.volume, Some(3));
|
||||
assert_eq!(found.method, Method::Volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publisher_shorthand_gives_membership_and_no_number() {
|
||||
let found = series_of("Aquinas (Companion)", Some("Cambridge")).expect("a series");
|
||||
assert_eq!(found.series, "Cambridge Companions");
|
||||
assert_eq!(found.volume, None, "membership is not a position");
|
||||
assert_eq!(
|
||||
found.proposed_title.as_deref(),
|
||||
Some("The Cambridge Companion to Aquinas")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_marker_wins_over_a_parenthetical() {
|
||||
// Both could match; the one carrying a number is worth more.
|
||||
let found = series_of("Aquinas (Companion) Vol 2", Some("Cambridge")).expect("a series");
|
||||
assert_eq!(found.method, Method::Volume);
|
||||
assert_eq!(found.volume, Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ordinary_title_proposes_nothing() {
|
||||
assert_eq!(series_of("Being and Time", Some("Heidegger")), None);
|
||||
assert_eq!(series_of("", None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publisher_shorthand_needs_the_publisher() {
|
||||
// The same title against anybody else is just a title.
|
||||
assert_eq!(series_of("Aquinas (Companion)", Some("Kerr")), None);
|
||||
assert_eq!(series_of("Aquinas (Companion)", None), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
//! The series a book belongs to, and which volume of it this is.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::Parser;
|
||||
use corpus_core::exit;
|
||||
|
||||
/// Read a series and a volume off a title, one record per proposal.
|
||||
///
|
||||
/// A shelf groups by series and volume, and most catalogues have neither. Four
|
||||
/// records all reading "A History of Philosophy" with nothing to say which
|
||||
/// volume any of them is, is what that looks like from the reading chair.
|
||||
///
|
||||
/// Reads one JSON record per line with a `title` and optionally an `author`,
|
||||
/// or plain titles with `--titles`. Says nothing about the books it cannot
|
||||
/// place.
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about,
|
||||
after_help = "\
|
||||
Exit codes: 0 something was proposed, 1 nothing was, 65 the input was not
|
||||
records, 74 the file could not be read.
|
||||
|
||||
Example:
|
||||
|
||||
seriesof catalogue.ndjson | jq -r '\"\\(.series) #\\(.volume // \"-\")\"'
|
||||
ls *.epub | sed 's/\\.epub$//' | seriesof --titles"
|
||||
)]
|
||||
struct Args {
|
||||
/// Records to read. Reads stdin when absent.
|
||||
path: Option<PathBuf>,
|
||||
|
||||
/// Read plain titles, one per line, rather than records.
|
||||
#[arg(long)]
|
||||
titles: bool,
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Args = match catena_cli::parse() {
|
||||
Ok(args) => args,
|
||||
Err(code) => return code,
|
||||
};
|
||||
|
||||
let text = match catena_cli::read_text(args.path.as_deref()) {
|
||||
Ok(text) => text,
|
||||
Err((code, why)) => {
|
||||
eprintln!("seriesof: {why}");
|
||||
return ExitCode::from(code);
|
||||
}
|
||||
};
|
||||
|
||||
let mut proposals = Vec::new();
|
||||
for (number, line) in text.lines().enumerate() {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (title, author) = match args.titles {
|
||||
true => (line.trim().to_string(), None),
|
||||
false => match read_record(line) {
|
||||
Ok(pair) => pair,
|
||||
Err(why) => {
|
||||
eprintln!("seriesof: line {}: {why}", number + 1);
|
||||
return ExitCode::from(exit::DATA);
|
||||
}
|
||||
},
|
||||
};
|
||||
if let Some(proposal) = seriesof::series_of(&title, author.as_deref()) {
|
||||
proposals.push(proposal);
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing to propose is an answer, and the usual one for a catalogue that
|
||||
// has already been through this.
|
||||
if proposals.is_empty() {
|
||||
return ExitCode::from(exit::NO);
|
||||
}
|
||||
catena_cli::stream("seriesof", &proposals)
|
||||
}
|
||||
|
||||
/// The title and author out of one record.
|
||||
fn read_record(line: &str) -> Result<(String, Option<String>), String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("not a record: {e}"))?;
|
||||
let title = value
|
||||
.get("title")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or("no title")?
|
||||
.to_string();
|
||||
let author = value
|
||||
.get("author")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string);
|
||||
Ok((title, author))
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Publisher-collection shorthand: "Aquinas (Companion)" against "Cambridge".
|
||||
//!
|
||||
//! The publishers and their collections are general reference data — a
|
||||
//! Cambridge Companion is the same thing on every shelf in the world — of the
|
||||
//! same kind as the publisher names elsewhere in this suite.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
|
||||
/// A trailing parenthetical short enough to be a collection name.
|
||||
static PAREN_KIND: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\(([^)]{3,40})\)\s*$").expect("a valid pattern"));
|
||||
|
||||
/// A trailing year, parenthesised or not, that a filename picked up.
|
||||
static TRAILING_YEAR: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?:\s*[\[(]\s*(?:19|20)\d{2}\s*[\])]\s*|\s*[.,\-]\s*(?:19|20)\d{2}\s*)$")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
|
||||
/// A second trailing parenthetical, once the first has been taken as the kind.
|
||||
static TRAILING_PAREN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"\s*[\[(][^\])]{0,40}[\])]\s*$").expect("a valid pattern"));
|
||||
|
||||
/// The publishers this shorthand appears with, and the name their collections
|
||||
/// are published under.
|
||||
static PUBLISHERS: LazyLock<HashMap<&'static str, &'static str>> = LazyLock::new(|| {
|
||||
[
|
||||
("cambridge", "Cambridge"),
|
||||
("oxford", "Oxford"),
|
||||
("blackwell", "Blackwell"),
|
||||
("wiley blackwell", "Blackwell"),
|
||||
("routledge", "Routledge"),
|
||||
("suny", "SUNY"),
|
||||
("palgrave", "Palgrave"),
|
||||
("springer", "Springer"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// What each kind of collection is called, and what its volumes are titled.
|
||||
///
|
||||
/// The title is `None` where the real one is not derivable from the shorthand
|
||||
/// — an "Anthology" or a "Studies" volume is not titled by any one pattern,
|
||||
/// and guessing produces things nobody published. For those only the series
|
||||
/// is proposed, which is the half that fixes browsing.
|
||||
static KINDS: LazyLock<HashMap<&'static str, (Option<&'static str>, &'static str)>> =
|
||||
LazyLock::new(|| {
|
||||
[
|
||||
("companion", (None, "{pub} Companions")),
|
||||
("companions", (None, "{pub} Companions")),
|
||||
("concise", (None, "{pub} Concise Companions")),
|
||||
("handbook", (None, "{pub} Handbooks")),
|
||||
(
|
||||
"guidebook",
|
||||
(
|
||||
Some("{pub} Philosophy Guidebook to {topic}"),
|
||||
"{pub} Philosophy Guidebooks",
|
||||
),
|
||||
),
|
||||
(
|
||||
"very short introduction",
|
||||
(
|
||||
Some("{topic}: A Very Short Introduction"),
|
||||
"Very Short Introductions",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contemporary debates",
|
||||
(
|
||||
Some("Contemporary Debates in {topic}"),
|
||||
"Contemporary Debates",
|
||||
),
|
||||
),
|
||||
(
|
||||
"contemporary introductions",
|
||||
(None, "{pub} Contemporary Introductions"),
|
||||
),
|
||||
("anthology", (None, "{pub} Anthologies")),
|
||||
("studies", (None, "{pub} Studies")),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Titles that depend on who published them.
|
||||
///
|
||||
/// The houses do not name their volumes alike, and getting it wrong renames a
|
||||
/// book. Cambridge and Routledge front theirs with the imprint; Blackwell does
|
||||
/// not — its Companions to Philosophy are "A Companion to Nietzsche", while a
|
||||
/// few of its religion titles genuinely are "The Blackwell Companion to …".
|
||||
/// That is unresolvable from the shorthand, so Blackwell gets its series and
|
||||
/// no title claim.
|
||||
static PUBLISHER_TITLES: LazyLock<HashMap<(&'static str, &'static str), &'static str>> =
|
||||
LazyLock::new(|| {
|
||||
[
|
||||
(
|
||||
("Cambridge", "companion"),
|
||||
"The Cambridge Companion to {topic}",
|
||||
),
|
||||
(
|
||||
("Cambridge", "companions"),
|
||||
"The Cambridge Companion to {topic}",
|
||||
),
|
||||
(
|
||||
("Routledge", "companion"),
|
||||
"The Routledge Companion to {topic}",
|
||||
),
|
||||
(
|
||||
("Routledge", "companions"),
|
||||
"The Routledge Companion to {topic}",
|
||||
),
|
||||
(("Oxford", "handbook"), "The Oxford Handbook of {topic}"),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// Reconstruct a title and a series from publisher shorthand.
|
||||
///
|
||||
/// Gives `(title, series)`, where the title is `None` for the kinds whose
|
||||
/// real titles do not follow one pattern. `None` altogether when the title
|
||||
/// carries no recognised parenthetical, or the author is not a publisher this
|
||||
/// shorthand shows up with.
|
||||
pub fn publisher_series(title: &str, author: &str) -> Option<(Option<String>, String)> {
|
||||
let title = title.trim();
|
||||
// Lowercased and trimmed, but runs of space inside are left alone: the
|
||||
// table is matched against exactly, and a doubled space in an author
|
||||
// field is as likely to be two different names run together as a typo.
|
||||
let author = author.trim().to_lowercase();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let publisher = *PUBLISHERS.get(author.as_str())?;
|
||||
|
||||
let found = PAREN_KIND.captures(title).ok().flatten()?;
|
||||
let kind = collapse(&found.get(1)?.as_str().trim().to_lowercase());
|
||||
let (title_template, series_template) = *KINDS.get(kind.as_str())?;
|
||||
|
||||
let trim = |text: &str| {
|
||||
text.trim_matches(|c: char| " ,:;.-–—".contains(c))
|
||||
.to_string()
|
||||
};
|
||||
let topic = trim(&title[..found.get(0)?.start()]);
|
||||
let topic = trim(&TRAILING_YEAR.replace(&topic, ""));
|
||||
let topic = trim(&TRAILING_PAREN.replace(&topic, ""));
|
||||
if topic.chars().count() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The per-publisher title first; the per-kind one only covers collections
|
||||
// that name themselves the same way whoever publishes them.
|
||||
let template = PUBLISHER_TITLES
|
||||
.get(&(publisher, kind.as_str()))
|
||||
.copied()
|
||||
.or(title_template);
|
||||
Some((
|
||||
template.map(|template| fill(template, publisher, &topic)),
|
||||
fill(series_template, publisher, &topic),
|
||||
))
|
||||
}
|
||||
|
||||
/// A template with its two placeholders filled.
|
||||
fn fill(template: &str, publisher: &str, topic: &str) -> String {
|
||||
template
|
||||
.replace("{pub}", publisher)
|
||||
.replace("{topic}", topic)
|
||||
}
|
||||
|
||||
/// Runs of whitespace down to one space.
|
||||
fn collapse(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_cambridge_companion_is_reconstructed_whole() {
|
||||
assert_eq!(
|
||||
publisher_series("Aquinas (Companion)", "Cambridge"),
|
||||
Some((
|
||||
Some("The Cambridge Companion to Aquinas".to_string()),
|
||||
"Cambridge Companions".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blackwell_gets_its_series_and_no_title_claim() {
|
||||
// Its companions are "A Companion to Nietzsche", not "The Blackwell
|
||||
// Companion to Nietzsche", and the shorthand cannot tell which.
|
||||
assert_eq!(
|
||||
publisher_series("Nietzsche (Companion)", "Blackwell"),
|
||||
Some((None, "Blackwell Companions".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collection_that_names_itself_the_same_way_everywhere_needs_no_publisher() {
|
||||
assert_eq!(
|
||||
publisher_series("Logic (Very Short Introduction)", "Oxford"),
|
||||
Some((
|
||||
Some("Logic: A Very Short Introduction".to_string()),
|
||||
"Very Short Introductions".to_string()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_year_is_not_part_of_the_topic() {
|
||||
for title in [
|
||||
"Christian Ethics (2004) (Companion)",
|
||||
"Christian Ethics.2009 (Companion)",
|
||||
"Christian Ethics [2006] (Companion)",
|
||||
] {
|
||||
let (proposed, _) = publisher_series(title, "Cambridge").expect("a series");
|
||||
assert_eq!(
|
||||
proposed.as_deref(),
|
||||
Some("The Cambridge Companion to Christian Ethics"),
|
||||
"{title}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_second_parenthetical_is_not_part_of_the_topic_either() {
|
||||
let (proposed, _) =
|
||||
publisher_series("British Drama [Contemporary] (Companion)", "Cambridge")
|
||||
.expect("a series");
|
||||
assert_eq!(
|
||||
proposed.as_deref(),
|
||||
Some("The Cambridge Companion to British Drama")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_author_who_is_not_a_publisher_proposes_nothing() {
|
||||
assert_eq!(publisher_series("Aquinas (Companion)", "Fergus Kerr"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_parenthetical_that_is_not_a_collection_proposes_nothing() {
|
||||
assert_eq!(
|
||||
publisher_series("Aquinas (Second Edition)", "Cambridge"),
|
||||
None
|
||||
);
|
||||
assert_eq!(publisher_series("Aquinas", "Cambridge"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shorthand_with_no_topic_left_proposes_nothing() {
|
||||
assert_eq!(publisher_series("(Companion)", "Cambridge"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_publisher_is_read_however_it_is_cased_or_padded() {
|
||||
assert!(publisher_series("Ethics (Companion)", " WILEY BLACKWELL ").is_some());
|
||||
// But a doubled space inside is a different string, not a typo to fix.
|
||||
assert!(publisher_series("Ethics (Companion)", "wiley blackwell").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! A volume marker in a title, and the series it implies.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use fancy_regex::Regex;
|
||||
|
||||
/// The roman numerals a volume is ever written in.
|
||||
///
|
||||
/// Twenty is where real collected editions stop; past that the numeral is far
|
||||
/// more often a century or a chapter than a volume.
|
||||
static ROMAN: LazyLock<HashMap<&'static str, u32>> = LazyLock::new(|| {
|
||||
[
|
||||
("i", 1),
|
||||
("ii", 2),
|
||||
("iii", 3),
|
||||
("iv", 4),
|
||||
("v", 5),
|
||||
("vi", 6),
|
||||
("vii", 7),
|
||||
("viii", 8),
|
||||
("ix", 9),
|
||||
("x", 10),
|
||||
("xi", 11),
|
||||
("xii", 12),
|
||||
("xiii", 13),
|
||||
("xiv", 14),
|
||||
("xv", 15),
|
||||
("xvi", 16),
|
||||
("xvii", 17),
|
||||
("xviii", 18),
|
||||
("xix", 19),
|
||||
("xx", 20),
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
});
|
||||
|
||||
/// "Vol 3", "Volume IX", "Book 1", "Part 2", "Tome II".
|
||||
///
|
||||
/// The unit word is required. A bare trailing number is far more often a
|
||||
/// year, an edition, or noise than a volume.
|
||||
static VOLUME: LazyLock<Regex> = LazyLock::new(|| {
|
||||
Regex::new(r"(?ix) [\s,:;.\-–—(\[]* \b(?:vol(?:ume)?s?|bk|book|part|pt|tome)\b [\s.:\#\-]* (?P<num>\d{1,3}|[ivxlc]{1,7}) \b[\s)\]]*")
|
||||
.expect("a valid pattern")
|
||||
});
|
||||
|
||||
/// The largest volume number worth believing.
|
||||
///
|
||||
/// Past two hundred it is a page, a year or a catalogue number that happens
|
||||
/// to sit after the word "book".
|
||||
const MAX_VOLUME: u32 = 200;
|
||||
|
||||
/// Parse `9` or `IX` to nine. `None` for anything that is neither.
|
||||
///
|
||||
/// Digits in any script, because the pattern that finds them matches the
|
||||
/// Unicode class and a catalogue in Arabic writes its volumes in Arabic
|
||||
/// figures.
|
||||
pub fn roman_or_int(text: &str) -> Option<u32> {
|
||||
let text = text.trim().to_lowercase();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if text.chars().all(char::is_numeric) {
|
||||
let number = textkit::decimal(&text);
|
||||
return match (1..=MAX_VOLUME).contains(&number) {
|
||||
true => Some(number),
|
||||
false => None,
|
||||
};
|
||||
}
|
||||
ROMAN.get(text.as_str()).copied()
|
||||
}
|
||||
|
||||
/// Split a volume marker off a title, giving the series and the number.
|
||||
///
|
||||
/// `None` when there is no marker, or when removing it leaves nothing that
|
||||
/// could be a series name.
|
||||
pub fn parse_volume(title: &str) -> Option<(String, u32)> {
|
||||
let title = title.trim();
|
||||
if title.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let found = VOLUME.captures(title).ok().flatten()?;
|
||||
let whole = found.get(0)?;
|
||||
let volume = roman_or_int(found.name("num")?.as_str())?;
|
||||
|
||||
// What comes before the marker is the series. Whatever follows is this
|
||||
// volume's own subtitle — "The Art of Computer Programming, Volume 3:
|
||||
// Sorting and Searching" is volume 3 *of* the former, and gluing the
|
||||
// halves back together gives a title nobody wrote.
|
||||
let mut name = &title[..whole.start()];
|
||||
|
||||
// The trailing-parenthetical convention puts the series inside the
|
||||
// brackets: "The Song of the Sirin (Raven Son Book 1)" is book one of
|
||||
// "Raven Son", not of "The Song of the Sirin (Raven Son".
|
||||
if let Some(open) = name.rfind(['(', '[']) {
|
||||
let after = &name[open..];
|
||||
if !after.contains(')') && !after.contains(']') {
|
||||
name = &name[open + 1..];
|
||||
}
|
||||
}
|
||||
|
||||
let name = collapse_spaces(name);
|
||||
let name = name.trim_matches(|c: char| " ,:;.-–—([".contains(c));
|
||||
let name = name.trim_end_matches(|c: char| " ,:;.-–—".contains(c));
|
||||
|
||||
// Nothing before the marker means the title never named a series. "Vol.
|
||||
// 2. PLATO, EPICTETUS, MARCUS AURELIUS" is a volume of something whose
|
||||
// name is simply not in the field, and filing it under its own contents
|
||||
// is worse than leaving it unfiled.
|
||||
if name.chars().count() < 4 || !has_word(name) {
|
||||
return None;
|
||||
}
|
||||
Some((name.to_string(), volume))
|
||||
}
|
||||
|
||||
/// Runs of whitespace down to one space.
|
||||
fn collapse_spaces(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut spaces = 0usize;
|
||||
for c in text.chars() {
|
||||
match c.is_whitespace() {
|
||||
true => spaces += 1,
|
||||
false => {
|
||||
if spaces > 0 {
|
||||
out.push(' ');
|
||||
spaces = 0;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if spaces > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether there are three letters in a row anywhere, which is the least a
|
||||
/// series name can be and still be a name.
|
||||
fn has_word(text: &str) -> bool {
|
||||
let mut run = 0;
|
||||
for c in text.chars() {
|
||||
run = match c.is_ascii_alphabetic() {
|
||||
true => run + 1,
|
||||
false => 0,
|
||||
};
|
||||
if run >= 3 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn arabic_and_roman_both_read_as_numbers() {
|
||||
assert_eq!(roman_or_int("9"), Some(9));
|
||||
assert_eq!(roman_or_int("IX"), Some(9));
|
||||
assert_eq!(roman_or_int(" ix "), Some(9));
|
||||
assert_eq!(roman_or_int("xx"), Some(20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_a_volume_number_is_not_one() {
|
||||
assert_eq!(roman_or_int(""), None);
|
||||
assert_eq!(roman_or_int("0"), None);
|
||||
assert_eq!(roman_or_int("201"), None);
|
||||
assert_eq!(roman_or_int("xxi"), None, "past twenty is not a volume");
|
||||
assert_eq!(roman_or_int("nine"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volume_written_in_another_script_is_still_a_volume() {
|
||||
assert_eq!(roman_or_int("٣"), Some(3));
|
||||
assert_eq!(roman_or_int("١٢"), Some(12));
|
||||
// A fraction is numeric and is not a digit run.
|
||||
assert_eq!(roman_or_int("½"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_series_is_what_comes_before_the_marker() {
|
||||
assert_eq!(
|
||||
parse_volume("The Philokalia Vol 3"),
|
||||
Some(("The Philokalia".to_string(), 3))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_volume("Collected Works of C.G. Jung, Volume 9"),
|
||||
Some(("Collected Works of C.G. Jung".to_string(), 9))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_volumes_own_subtitle_is_not_part_of_the_series_name() {
|
||||
assert_eq!(
|
||||
parse_volume("The Art of Computer Programming, Volume 3: Sorting and Searching"),
|
||||
Some(("The Art of Computer Programming".to_string(), 3))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_series_named_inside_a_parenthetical_is_the_series() {
|
||||
assert_eq!(
|
||||
parse_volume("The Song of the Sirin (Raven Son Book 1)"),
|
||||
Some(("Raven Son".to_string(), 1))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_that_never_named_a_series_is_left_unfiled() {
|
||||
// A Harvard Classics volume, whose series name is not in the field.
|
||||
assert_eq!(
|
||||
parse_volume("Vol. 2. PLATO, EPICTETUS, MARCUS AURELIUS"),
|
||||
None
|
||||
);
|
||||
assert_eq!(parse_volume("Book 1"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bare_number_is_not_a_volume() {
|
||||
// Far more often a year, an edition, or noise.
|
||||
assert_eq!(parse_volume("Being and Time 1927"), None);
|
||||
assert_eq!(parse_volume("Ethics 2"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_in_nothing_out() {
|
||||
assert_eq!(parse_volume(""), None);
|
||||
assert_eq!(parse_volume(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roman_volumes_read_the_same_as_arabic_ones() {
|
||||
assert_eq!(
|
||||
parse_volume("A History of Philosophy, Vol. IV"),
|
||||
Some(("A History of Philosophy".to_string(), 4))
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user