use anyhow::{Result, anyhow};
use serde_json::Value as Json;
use crate::config::GutenbergConfig;
#[derive(Debug, Clone)]
pub(super) struct GutenbergBook {
pub id: i64,
pub title: String,
pub authors: Vec<String>,
pub subjects: Vec<String>,
pub text_url: String,
}
impl GutenbergBook {
pub(super) fn to_bibentry(&self) -> crate::sources::BibEntry {
let surname: String = self
.authors
.first()
.map(|a| a.split(',').next().unwrap_or(a).trim().to_ascii_lowercase())
.unwrap_or_default()
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect();
let key = if surname.is_empty() { format!("pg{}", self.id) } else { format!("{surname}pg{}", self.id) };
crate::sources::BibEntry {
key,
entry_type: "book".to_string(),
author: self.authors.join(" and "),
title: self.title.clone(),
url: Some(format!("https://www.gutenberg.org/ebooks/{}", self.id)),
note: Some(format!("Project Gutenberg #{}", self.id)),
..Default::default()
}
}
}
pub(super) fn available(cfg: &GutenbergConfig) -> bool {
cfg.enabled
}
fn client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent("inkhaven-research/1.0 (https://crates.io/crates/inkhaven)")
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| anyhow!("http client: {e}"))
}
pub(super) async fn fetch(
cfg: GutenbergConfig,
query: String,
language: String,
) -> Result<(GutenbergBook, String, Vec<GutenbergBook>)> {
let base = cfg.endpoint.trim_end_matches('/').to_string();
let client = client()?;
let (chosen, alternatives) = if let Ok(id) = query.trim().parse::<i64>() {
let json: Json = client
.get(format!("{base}/books/{id}"))
.send()
.await
.map_err(|e| anyhow!("gutenberg lookup: {e}"))?
.json()
.await
.map_err(|e| anyhow!("gutenberg decode: {e}"))?;
let book = parse_book(&json)
.ok_or_else(|| anyhow!("Project Gutenberg #{id} has no plain-text edition"))?;
(book, Vec::new())
} else {
let mut books = search_books(&client, &base, &query, &language).await?;
if books.is_empty() {
return Err(anyhow!("no Project Gutenberg book for `{query}`"));
}
let chosen = books.remove(0);
books.truncate(4);
(chosen, books)
};
let raw = client
.get(&chosen.text_url)
.send()
.await
.map_err(|e| anyhow!("gutenberg text: {e}"))?
.text()
.await
.map_err(|e| anyhow!("gutenberg text decode: {e}"))?;
let stripped = strip_pg_boilerplate(&raw);
let capped: String = stripped.chars().take(cfg.max_chars.max(1000)).collect();
Ok((chosen, capped, alternatives))
}
async fn search_books(
client: &reqwest::Client,
base: &str,
query: &str,
language: &str,
) -> Result<Vec<GutenbergBook>> {
let mut q: Vec<(&str, String)> = vec![("search", query.to_string())];
if !language.is_empty() {
q.push(("languages", language.to_string()));
}
let json: Json = client
.get(format!("{base}/books"))
.query(&q)
.send()
.await
.map_err(|e| anyhow!("gutenberg search: {e}"))?
.json()
.await
.map_err(|e| anyhow!("gutenberg decode: {e}"))?;
Ok(json
.get("results")
.and_then(|r| r.as_array())
.map(|a| a.iter().filter_map(parse_book).collect())
.unwrap_or_default())
}
fn parse_book(r: &Json) -> Option<GutenbergBook> {
Some(GutenbergBook {
id: r.get("id").and_then(|v| v.as_i64()).unwrap_or(0),
title: r.get("title").and_then(|v| v.as_str()).unwrap_or("Untitled").to_string(),
authors: r
.get("authors")
.and_then(|a| a.as_array())
.map(|a| a.iter().filter_map(|x| x.get("name")?.as_str().map(str::to_string)).collect())
.unwrap_or_default(),
subjects: r
.get("subjects")
.and_then(|s| s.as_array())
.map(|s| s.iter().filter_map(|x| x.as_str().map(str::to_string)).take(4).collect())
.unwrap_or_default(),
text_url: text_url_of(r)?,
})
}
pub(super) fn split_chapters(text: &str) -> Vec<String> {
let mut starts: Vec<usize> = Vec::new();
let mut offset = 0usize;
for line in text.split_inclusive('\n') {
if is_chapter_heading(line.trim()) {
starts.push(offset);
}
offset += line.len();
}
if starts.is_empty() {
return vec![text.to_string()];
}
let mut chapters = Vec::new();
for (i, &s) in starts.iter().enumerate() {
let end = starts.get(i + 1).copied().unwrap_or(text.len());
let seg = text[s..end].trim();
if !seg.is_empty() {
chapters.push(seg.to_string());
}
}
chapters
}
fn is_chapter_heading(line: &str) -> bool {
if line.is_empty() || line.chars().count() > 60 {
return false;
}
let upper = line.to_uppercase();
const HEADS: &[&str] = &[
"CHAPTER ", "PART ", "BOOK ", "ГЛАВА ", "ЧАСТЬ ", "КНИГА ", "CHAPITRE ", "PARTIE ", "LIVRE ", "KAPITEL ", "TEIL ", "BUCH ", "CAPÍTULO ", "CAPITULO ", "PARTE ", "LIBRO ", ];
if HEADS.iter().any(|h| upper.starts_with(h)) {
return true;
}
let head = line.split_whitespace().next().unwrap_or("").trim_end_matches('.');
!head.is_empty()
&& (head.chars().all(|c| "IVXLCDM".contains(c.to_ascii_uppercase()))
|| head.chars().all(|c| c.is_ascii_digit()))
&& line.split_whitespace().count() <= 3
}
fn text_url_of(book: &Json) -> Option<String> {
let formats = book.get("formats")?.as_object()?;
formats
.iter()
.filter(|(k, _)| k.starts_with("text/plain"))
.filter_map(|(_, v)| v.as_str())
.find(|u| !u.ends_with(".zip"))
.map(str::to_string)
}
pub(super) fn strip_pg_boilerplate(text: &str) -> String {
let start = text
.find("*** START OF THE PROJECT GUTENBERG")
.or_else(|| text.find("*** START OF THIS PROJECT GUTENBERG"))
.and_then(|i| text[i..].find('\n').map(|j| i + j + 1))
.unwrap_or(0);
let end = text[start..]
.find("*** END OF THE PROJECT GUTENBERG")
.or_else(|| text[start..].find("*** END OF THIS PROJECT GUTENBERG"))
.map(|e| start + e)
.unwrap_or(text.len());
text[start..end].trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognizes_multilingual_chapter_headings() {
assert!(is_chapter_heading("CHAPTER I"));
assert!(is_chapter_heading("ГЛАВА ПЕРВАЯ")); assert!(is_chapter_heading("глава 3")); assert!(is_chapter_heading("CHAPITRE II")); assert!(is_chapter_heading("Kapitel 4")); assert!(is_chapter_heading("Capítulo V")); assert!(is_chapter_heading("ЧАСТЬ первая длинное название раздела"));
assert!(!is_chapter_heading("just some ordinary prose line here"));
}
#[test]
fn strips_pg_boilerplate() {
let doc = "The Project Gutenberg eBook of X\nlicense blah\n\
*** START OF THE PROJECT GUTENBERG EBOOK X ***\n\
Chapter I. It was a bright cold day.\n\
*** END OF THE PROJECT GUTENBERG EBOOK X ***\n\
footer blah";
let body = strip_pg_boilerplate(doc);
assert_eq!(body, "Chapter I. It was a bright cold day.");
assert_eq!(strip_pg_boilerplate(" plain body "), "plain body");
}
#[test]
fn parses_gutendex_result() {
let j = serde_json::json!({
"id": 1342, "title": "Pride and Prejudice",
"authors": [{"name": "Austen, Jane"}],
"subjects": ["England -- Fiction", "Love stories"],
"formats": {
"application/epub+zip": "https://x/1342.epub",
"text/plain; charset=utf-8": "https://www.gutenberg.org/ebooks/1342.txt.utf-8"
}
});
let b = parse_book(&j).unwrap();
assert_eq!(b.id, 1342);
assert_eq!(b.title, "Pride and Prejudice");
assert_eq!(b.authors, vec!["Austen, Jane"]);
assert!(b.text_url.ends_with("1342.txt.utf-8"));
}
#[test]
fn skips_result_without_plaintext() {
let audio = serde_json::json!({"id": 1, "title": "audio only", "formats": {"audio/mpeg": "https://x/a.mp3"}});
let text = serde_json::json!({"id": 2, "title": "has text", "formats": {"text/plain": "https://x/2.txt"}});
assert!(parse_book(&audio).is_none());
assert_eq!(parse_book(&text).unwrap().id, 2);
}
#[test]
fn book_to_bibentry() {
let b = GutenbergBook {
id: 1342,
title: "Pride and Prejudice".into(),
authors: vec!["Austen, Jane".into()],
subjects: vec![],
text_url: "https://x".into(),
};
let e = b.to_bibentry();
assert_eq!(e.key, "austenpg1342");
assert_eq!(e.entry_type, "book");
assert_eq!(e.author, "Austen, Jane");
assert_eq!(e.note.as_deref(), Some("Project Gutenberg #1342"));
assert!(e.is_valid());
}
#[test]
fn splits_chapters_on_headings() {
let text = "Preamble matter.\nCHAPTER I\nThe first bit.\nCHAPTER II\nThe second bit.";
let ch = split_chapters(text);
assert_eq!(ch.len(), 2);
assert!(ch[0].starts_with("CHAPTER I"));
assert!(ch[1].contains("second bit"));
assert_eq!(split_chapters("just prose, no headings").len(), 1);
}
}