mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-10-23 03:07:13 +00:00
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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user