mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
305 lines
10 KiB
Rust
305 lines
10 KiB
Rust
//! A closed subject scheme, and what it says about a heading.
|
|
//!
|
|
//! A catalogue that lets anything be a subject ends up with forty thousand of
|
|
//! them and no way to browse. A scheme is the other approach: a fixed list of
|
|
//! headings, everything else mapped onto one or dropped.
|
|
//!
|
|
//! The scheme itself is supplied rather than compiled in. Which subjects a
|
|
//! collection uses, and what its parents are, is the most collection-specific
|
|
//! thing there is — a theology library and a science library agree on nothing
|
|
//! here — so it arrives as a file and this only knows what to do with one.
|
|
|
|
use std::collections::HashMap;
|
|
use std::sync::LazyLock;
|
|
|
|
use fancy_regex::Regex;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
/// Titles that say the book is *about* somebody rather than by them, or is a
|
|
/// study rather than a story.
|
|
///
|
|
/// General reference rather than collection-specific: a Companion is a
|
|
/// Companion on any shelf.
|
|
static STUDY_TITLE: LazyLock<Regex> = LazyLock::new(|| {
|
|
Regex::new(r"(?i)\b(companion to|guidebook to|very short introduction|letters of|collected letters|memoirs?\b|autobiograph|\bdiary\b|\bessays\b|\blectures\b|\bspeeches\b|a life of|biography of)\b")
|
|
.expect("a valid pattern")
|
|
});
|
|
|
|
/// A subject scheme: what the headings are, and how they relate.
|
|
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)]
|
|
pub struct Scheme {
|
|
/// Every heading the scheme allows, in the order they should be listed.
|
|
#[serde(default)]
|
|
pub subjects: Vec<String>,
|
|
/// Spellings that mean one of the subjects: alias to subject.
|
|
#[serde(default)]
|
|
pub aliases: HashMap<String, String>,
|
|
/// Headings too broad or too incidental to keep, whatever they say.
|
|
#[serde(default)]
|
|
pub drop: Vec<String>,
|
|
/// Which subject each subject sits under, child to parent.
|
|
#[serde(default)]
|
|
pub parents: HashMap<String, String>,
|
|
/// Subjects that mean the work is a study, so it cannot also be fiction.
|
|
#[serde(default)]
|
|
pub blocks_fiction: Vec<String>,
|
|
/// The genres a study blocks.
|
|
#[serde(default)]
|
|
pub fiction_genres: Vec<String>,
|
|
}
|
|
|
|
impl Scheme {
|
|
/// Whether the scheme says anything at all.
|
|
pub fn is_empty(&self) -> bool {
|
|
self.subjects.is_empty()
|
|
}
|
|
|
|
/// The subject a heading means, or `None` if it is not one.
|
|
///
|
|
/// Exact first, then an alias. A heading on the drop list is not a
|
|
/// subject however it is spelled, and the brackets a catalogue wraps its
|
|
/// own annotations in are stripped before the comparison.
|
|
pub fn canonical(&self, name: &str) -> Option<&str> {
|
|
let name = name.trim();
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
let lower = name.to_lowercase();
|
|
let bare = lower.trim_matches(|c: char| "*[]".contains(c));
|
|
|
|
if self.drop.iter().any(|drop| {
|
|
let drop = drop.to_lowercase();
|
|
let drop = drop.trim_matches(|c: char| "*[]".contains(c));
|
|
drop == lower || drop == bare
|
|
}) {
|
|
return None;
|
|
}
|
|
if let Some(found) = self.subjects.iter().find(|s| s.to_lowercase() == lower) {
|
|
return Some(found);
|
|
}
|
|
self.aliases
|
|
.iter()
|
|
.find(|(alias, _)| alias.to_lowercase() == lower)
|
|
.map(|(_, subject)| subject.as_str())
|
|
}
|
|
|
|
/// Every chosen subject, with the parent of each carried alongside it.
|
|
///
|
|
/// Phenomenology becomes Philosophy and Phenomenology, so a shelf browsed
|
|
/// by the parent still finds the child. Returned in scheme order, which
|
|
/// is the order a reader expects to see them listed.
|
|
pub fn with_parents(&self, subjects: &[String]) -> Vec<String> {
|
|
let mut chosen: Vec<&str> = Vec::new();
|
|
for subject in subjects {
|
|
if !self.subjects.iter().any(|s| s == subject) {
|
|
continue;
|
|
}
|
|
chosen.push(subject);
|
|
if let Some(parent) = self.parents.get(subject) {
|
|
chosen.push(parent);
|
|
}
|
|
}
|
|
self.in_order(&chosen)
|
|
}
|
|
|
|
/// Drop the fiction genres where the work is plainly a study.
|
|
///
|
|
/// A historical novel may be fiction and history at once. A life of
|
|
/// somebody, a Companion, or a writer's diary may not be a novel.
|
|
pub fn without_impossible_fiction(
|
|
&self,
|
|
subjects: &[String],
|
|
title: Option<&str>,
|
|
) -> Vec<String> {
|
|
let study = title.is_some_and(|title| STUDY_TITLE.is_match(title).unwrap_or(false));
|
|
let blocked = subjects
|
|
.iter()
|
|
.any(|s| self.blocks_fiction.iter().any(|block| block == s));
|
|
|
|
let kept: Vec<&str> = subjects
|
|
.iter()
|
|
.map(String::as_str)
|
|
.filter(|subject| {
|
|
!(study || blocked) || !self.fiction_genres.iter().any(|genre| genre == subject)
|
|
})
|
|
.collect();
|
|
self.in_order(&kept)
|
|
}
|
|
|
|
/// The subjects a book should carry: the chosen ones, their parents, and
|
|
/// nothing the title rules out.
|
|
pub fn compose(&self, subjects: &[String], title: Option<&str>) -> Vec<String> {
|
|
self.without_impossible_fiction(&self.with_parents(subjects), title)
|
|
}
|
|
|
|
/// Whether a book has no real subject yet.
|
|
///
|
|
/// Untagged, or tagged only with headings the scheme does not recognise —
|
|
/// which is what a catalogue built from filenames looks like.
|
|
pub fn needs_subject(&self, existing: &[String]) -> bool {
|
|
!existing.iter().any(|tag| self.canonical(tag).is_some())
|
|
}
|
|
|
|
/// The given headings, deduplicated and in scheme order.
|
|
fn in_order(&self, chosen: &[&str]) -> Vec<String> {
|
|
self.subjects
|
|
.iter()
|
|
.filter(|subject| chosen.contains(&subject.as_str()))
|
|
.cloned()
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn scheme() -> Scheme {
|
|
Scheme {
|
|
subjects: [
|
|
"Philosophy",
|
|
"Phenomenology",
|
|
"History",
|
|
"Biography",
|
|
"Fiction",
|
|
"Science Fiction",
|
|
]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect(),
|
|
aliases: [("Philosophie", "Philosophy"), ("SF", "Science Fiction")]
|
|
.iter()
|
|
.map(|(a, b)| (a.to_string(), b.to_string()))
|
|
.collect(),
|
|
drop: ["General", "Humanities", "Non-Fiction"]
|
|
.iter()
|
|
.map(|s| s.to_string())
|
|
.collect(),
|
|
parents: [("Phenomenology", "Philosophy")]
|
|
.iter()
|
|
.map(|(a, b)| (a.to_string(), b.to_string()))
|
|
.collect(),
|
|
blocks_fiction: vec!["Biography".into()],
|
|
fiction_genres: vec!["Fiction".into(), "Science Fiction".into()],
|
|
}
|
|
}
|
|
|
|
fn subjects(names: &[&str]) -> Vec<String> {
|
|
names.iter().map(|s| s.to_string()).collect()
|
|
}
|
|
|
|
#[test]
|
|
fn a_heading_in_the_scheme_is_itself() {
|
|
assert_eq!(scheme().canonical("Philosophy"), Some("Philosophy"));
|
|
assert_eq!(scheme().canonical("philosophy"), Some("Philosophy"));
|
|
}
|
|
|
|
#[test]
|
|
fn an_alias_is_the_subject_it_means() {
|
|
assert_eq!(scheme().canonical("Philosophie"), Some("Philosophy"));
|
|
assert_eq!(scheme().canonical("sf"), Some("Science Fiction"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_heading_on_the_drop_list_is_not_a_subject() {
|
|
assert_eq!(scheme().canonical("General"), None);
|
|
// Including inside the brackets a catalogue wraps its own notes in.
|
|
assert_eq!(scheme().canonical("[General]"), None);
|
|
assert_eq!(scheme().canonical("*Humanities*"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_heading_the_scheme_never_heard_of_is_not_a_subject() {
|
|
assert_eq!(scheme().canonical("Underwater Basketry"), None);
|
|
assert_eq!(scheme().canonical(""), None);
|
|
assert_eq!(scheme().canonical(" "), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_child_carries_its_parent() {
|
|
assert_eq!(
|
|
scheme().with_parents(&subjects(&["Phenomenology"])),
|
|
["Philosophy", "Phenomenology"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn subjects_come_back_in_scheme_order_however_they_went_in() {
|
|
assert_eq!(
|
|
scheme().with_parents(&subjects(&["History", "Philosophy"])),
|
|
["Philosophy", "History"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_heading_outside_the_scheme_is_dropped_rather_than_carried() {
|
|
assert_eq!(
|
|
scheme().with_parents(&subjects(&["Basketry"])),
|
|
Vec::<String>::new()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_biography_is_not_also_a_novel() {
|
|
assert_eq!(
|
|
scheme().without_impossible_fiction(&subjects(&["Biography", "Fiction"]), None),
|
|
["Biography"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_title_that_says_study_rules_out_fiction_on_its_own() {
|
|
for title in [
|
|
"The Cambridge Companion to Nietzsche",
|
|
"Logic: A Very Short Introduction",
|
|
"A Life of Wrangel",
|
|
"The Diary of a Writer",
|
|
] {
|
|
assert_eq!(
|
|
scheme()
|
|
.without_impossible_fiction(&subjects(&["Fiction", "History"]), Some(title)),
|
|
["History"],
|
|
"{title}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_historical_novel_may_still_be_both() {
|
|
assert_eq!(
|
|
scheme().without_impossible_fiction(
|
|
&subjects(&["Fiction", "History"]),
|
|
Some("War and Peace")
|
|
),
|
|
["History", "Fiction"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn composing_does_the_parents_and_the_fiction_rule_at_once() {
|
|
assert_eq!(
|
|
scheme().compose(
|
|
&subjects(&["Phenomenology", "Fiction"]),
|
|
Some("A Life of Husserl")
|
|
),
|
|
["Philosophy", "Phenomenology"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_book_tagged_only_with_rubbish_still_needs_a_subject() {
|
|
assert!(scheme().needs_subject(&[]));
|
|
assert!(scheme().needs_subject(&subjects(&["General", "ebook"])));
|
|
assert!(!scheme().needs_subject(&subjects(&["General", "Philosophy"])));
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_scheme_says_nothing_about_anything() {
|
|
let empty = Scheme::default();
|
|
assert!(empty.is_empty());
|
|
assert_eq!(empty.canonical("Philosophy"), None);
|
|
assert!(empty.needs_subject(&subjects(&["Philosophy"])));
|
|
}
|
|
}
|