use anyhow::{Result, anyhow};
use serde_json::Value as Json;
use crate::config::ArchiveConfig;
#[derive(Debug, Clone)]
pub(super) struct ArchiveItem {
pub identifier: String,
pub title: String,
pub authors: Vec<String>,
pub year: String,
#[allow(dead_code)]
pub description: String,
pub text_url: String,
}
impl ArchiveItem {
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 slug: String = self
.identifier
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.take(16)
.collect();
let key = if surname.is_empty() {
format!("ia{slug}")
} else {
format!("{surname}ia{slug}")
};
crate::sources::BibEntry {
key,
entry_type: "book".to_string(),
author: self.authors.join(" and "),
title: self.title.clone(),
year: self.year.clone(),
url: Some(format!("https://archive.org/details/{}", self.identifier)),
note: Some(format!("Internet Archive: {}", self.identifier)),
..Default::default()
}
}
}
pub(super) fn available(cfg: &ArchiveConfig) -> 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: ArchiveConfig,
query: String,
language: String,
) -> Result<(ArchiveItem, String, Vec<ArchiveItem>)> {
let base = cfg.endpoint.trim_end_matches('/').to_string();
let client = client()?;
let mut items = search_items(&client, &base, &query, &language).await?;
if items.is_empty() {
return Err(anyhow!("no Internet Archive text for `{query}`"));
}
let chosen = items.remove(0);
items.truncate(4);
let raw = client
.get(&chosen.text_url)
.send()
.await
.map_err(|e| anyhow!("archive text: {e}"))?
.text()
.await
.map_err(|e| anyhow!("archive text decode: {e}"))?;
let capped: String = raw.trim().chars().take(cfg.max_chars.max(1000)).collect();
if capped.trim().is_empty() {
return Err(anyhow!(
"`{}` has no OCR plain text (try another match)",
chosen.identifier
));
}
Ok((chosen, capped, items))
}
async fn search_items(
client: &reqwest::Client,
base: &str,
query: &str,
language: &str,
) -> Result<Vec<ArchiveItem>> {
let mut q = format!("({query}) AND mediatype:texts");
if !language.is_empty() {
q.push_str(&format!(" AND language:{language}"));
}
let params: Vec<(&str, String)> = vec![
("q", q),
("fl[]", "identifier".to_string()),
("fl[]", "title".to_string()),
("fl[]", "creator".to_string()),
("fl[]", "year".to_string()),
("fl[]", "description".to_string()),
("sort[]", "downloads desc".to_string()),
("rows", "5".to_string()),
("page", "1".to_string()),
("output", "json".to_string()),
];
let json: Json = client
.get(format!("{base}/advancedsearch.php"))
.query(¶ms)
.send()
.await
.map_err(|e| anyhow!("archive search: {e}"))?
.json()
.await
.map_err(|e| anyhow!("archive decode: {e}"))?;
Ok(json
.get("response")
.and_then(|r| r.get("docs"))
.and_then(|d| d.as_array())
.map(|a| a.iter().filter_map(|d| parse_item(base, d)).collect())
.unwrap_or_default())
}
fn parse_item(base: &str, d: &Json) -> Option<ArchiveItem> {
let identifier = d.get("identifier")?.as_str()?.trim().to_string();
if identifier.is_empty() {
return None;
}
let title = {
let t = d.get("title").map(json_first_string).unwrap_or_default();
if t.is_empty() { "Untitled".to_string() } else { t }
};
Some(ArchiveItem {
text_url: format!("{base}/download/{identifier}/{identifier}_djvu.txt"),
authors: json_string_list(d.get("creator")),
year: d.get("year").map(json_first_string).unwrap_or_default(),
description: d.get("description").map(json_first_string).unwrap_or_default(),
title,
identifier,
})
}
fn json_first_string(v: &Json) -> String {
match v {
Json::String(s) => s.trim().to_string(),
Json::Array(a) => a
.iter()
.find_map(|x| x.as_str())
.unwrap_or("")
.trim()
.to_string(),
_ => String::new(),
}
}
fn json_string_list(v: Option<&Json>) -> Vec<String> {
match v {
Some(Json::String(s)) => vec![s.trim().to_string()],
Some(Json::Array(a)) => a
.iter()
.filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect(),
_ => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_advancedsearch_doc_string_and_array_fields() {
let d = serde_json::json!({
"identifier": "gita-as-it-is",
"title": "Bhagavad Gita",
"creator": ["Vyasa", "Trans. Anon"],
"year": "1901",
"description": "A public-domain translation."
});
let item = parse_item("https://archive.org", &d).unwrap();
assert_eq!(item.identifier, "gita-as-it-is");
assert_eq!(item.title, "Bhagavad Gita");
assert_eq!(item.authors, vec!["Vyasa", "Trans. Anon"]);
assert_eq!(item.year, "1901");
assert!(item.text_url.ends_with("gita-as-it-is/gita-as-it-is_djvu.txt"));
let d2 = serde_json::json!({ "identifier": "x", "creator": "Kant, Immanuel" });
let item2 = parse_item("https://archive.org", &d2).unwrap();
assert_eq!(item2.authors, vec!["Kant, Immanuel"]);
assert_eq!(item2.title, "Untitled");
assert!(parse_item("https://archive.org", &serde_json::json!({ "title": "no id" })).is_none());
}
#[test]
fn item_to_bibentry() {
let item = ArchiveItem {
identifier: "critiqueofpurer00kant".into(),
title: "Critique of Pure Reason".into(),
authors: vec!["Kant, Immanuel".into()],
year: "1901".into(),
description: String::new(),
text_url: "https://x".into(),
};
let e = item.to_bibentry();
assert_eq!(e.key, "kantiacritiqueofpurer0");
assert_eq!(e.entry_type, "book");
assert_eq!(e.author, "Kant, Immanuel");
assert_eq!(e.year, "1901");
assert_eq!(e.note.as_deref(), Some("Internet Archive: critiqueofpurer00kant"));
assert!(e.is_valid());
}
}