colophon: the record a book prints in its own front matter

A catalogue's worst records are the ones whose file says nothing true about
itself: a PDF titled `Untitled` by an author called `Oxford`, which is a
publisher, from a filename that was never anything but a filename. What
those books do have is pages. The title page carries the title as the
publisher set it; the copyright page carries the imprint, the year, the
ISBN, and in most English books since the 1970s the cataloguing block, which
is a catalogue record a librarian already made, subject headings and all.

So this reads the pages. Every field names the page it came from, and a book
that printed nothing comes back empty with its fields in `unsettled` rather
than filled with something plausible — an extraction that starts at chapter
one has no front matter in it, and saying so is the answer.

Two pages look like a title page and are not, and both are how a catalogue
comes to name the wrong book. A series advertisement lists other titles in
the shape a title page uses, over as many as four pages with only the first
announcing itself. A translation's cataloguing block prints the original
title in brackets above the real one. Both are found and refused.

Three things the pages give that no file does: the subject headings a
librarian assigned, an ISBN that exists only in print — which turns a book
with no identifier into one `bibfetch` can look up — and a title in the case
the publisher meant, because a half title prints unbroken what the title
page breaks across two lines and sets in capitals.

`--prompt` and `--merge` are citefind's: a question about exactly the fields
the pages left unsettled, composed here and answered wherever the caller
keeps a model, with the advertising taken out of what the model is shown.
A field a model answered is marked `model`, so a record always says which of
it was read and which was inferred.

Measured against 600 books of the home corpus: the cataloguing block
answered for 21% of them and agreed with the catalogue 89% of the time, most
of the residue being the catalogue's error rather than this one's. Title
pages answered for another 41%. The remaining 38% print nothing readable,
and are what the prompt is for.
This commit is contained in:
funkpower
2026-09-21 09:28:26 -04:00
parent d55486977d
commit ba8ec81735
13 changed files with 2064 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
//! 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,
}
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);
}
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(&text, args.pages);
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)
}