seriesof: the series a book belongs to, and which volume this is

This commit is contained in:
funkpower
2026-09-19 19:03:47 -04:00
parent e9d22ee1fa
commit 05b363ea8a
25 changed files with 1406 additions and 4 deletions
+147
View File
@@ -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);
}
}
+96
View File
@@ -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))
}
+265
View File
@@ -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());
}
}
+243
View File
@@ -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))
);
}
}