use std::io::{Read, Write};
use serde::{Deserialize, Serialize};
use crate::{
error::{LogoxideError, Result},
manifest::{Format, PackageKind},
package::Package,
};
pub const REFERENCES: &str = "logoxide.references.bin";
const MAGIC: &[u8; 4] = b"LXR1";
pub const MAX_REFERENCES_BYTES: u64 = 64 << 20;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct FoundReference {
pub passage: String,
pub file: String,
pub cfi: String,
pub chapter: Option<String>,
pub start: u32,
pub end: u32,
pub line: u32,
pub column: u32,
pub spine: u32,
pub line_text: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Cited {
pub passage: String,
pub count: u32,
}
pub fn epub_files(package: &Package) -> Result<Vec<String>> {
let declared = crate::package::declared_files(package.storage().as_ref())?;
Ok((declared.into_iter())
.filter(|f| f.to_ascii_lowercase().ends_with(".epub"))
.filter(|f| {
(package.manifest().formats(package.enabled()).iter()).any(|(name, entry)| {
let dir = entry.path.trim_end_matches('/');
Format::from_name(name) == Some(Format::Epub)
&& (f.as_str() == dir || f.starts_with(&format!("{dir}/")))
})
})
.collect())
}
pub fn book_files(package: &Package) -> Result<Vec<String>> {
let mut files = epub_files(package)?;
if package.info().kind == PackageKind::Book {
let declared = crate::package::declared_files(package.storage().as_ref())?;
let formats = package.manifest().formats(package.enabled());
files.extend(declared.into_iter().filter(|f| {
let lower = f.to_ascii_lowercase();
(lower.ends_with(".xml") || lower.ends_with(".thml"))
&& formats.iter().any(|(name, entry)| {
let dir = entry.path.trim_end_matches('/');
Format::from_name(name) == Some(Format::Thml)
&& (f.as_str() == dir || f.starts_with(&format!("{dir}/")))
})
}));
files.sort();
}
Ok(files)
}
#[cfg(feature = "read")]
pub fn find(package: &Package) -> Result<Vec<FoundReference>> {
let files = crate::reader::StorageFiles(package.storage().clone());
let found = logoxide_text::citations::find(&files, &book_files(package)?)?;
Ok((found.into_iter())
.map(|c| FoundReference {
passage: c.passage,
file: c.file,
cfi: c.cfi,
chapter: c.chapter,
start: c.start,
end: c.end,
line: c.line,
column: c.column,
spine: c.spine,
line_text: c.line_text,
})
.collect())
}
pub fn cited(references: &[FoundReference]) -> Vec<Cited> {
let books = crate::bible_data().books();
let mut counts: std::collections::HashMap<&str, u32> = std::collections::HashMap::new();
for r in references {
*counts.entry(r.passage.as_str()).or_default() += 1;
}
let mut cited: Vec<Cited> = (counts.into_iter())
.map(|(passage, count)| Cited {
passage: passage.to_string(),
count,
})
.collect();
let key = |passage: &str| {
let mut parts = passage.split(['.', '-']);
let book = parts.next().and_then(|b| books.search_osis(b)).map(|b| b.0);
let numbers: Vec<u32> = parts.take(2).map(|n| n.parse().unwrap_or(0)).collect();
(book.unwrap_or(u8::MAX), numbers, passage.to_string())
};
cited.sort_by_cached_key(|c| key(&c.passage));
cited
}
pub fn encode(cited: &[Cited]) -> Result<Vec<u8>> {
let body = postcard::to_stdvec(cited).map_err(|e| LogoxideError::Cache(e.to_string()))?;
let mut out = MAGIC.to_vec();
let mut deflate = flate2::write::DeflateEncoder::new(&mut out, flate2::Compression::best());
deflate
.write_all(&body)
.and_then(|()| deflate.finish().map(|_| ()))
.map_err(|e| LogoxideError::Cache(e.to_string()))?;
Ok(out)
}
pub fn decode(bytes: &[u8]) -> Result<Vec<Cited>> {
let bad = |message: String| LogoxideError::Manifest(format!("{REFERENCES}: {message}"));
let body = bytes
.strip_prefix(MAGIC.as_slice())
.ok_or_else(|| bad(String::from("not a LXR1 references file")))?;
let mut inflated = vec![];
flate2::read::DeflateDecoder::new(body)
.take(MAX_REFERENCES_BYTES + 1)
.read_to_end(&mut inflated)
.map_err(|e| bad(e.to_string()))?;
if inflated.len() as u64 > MAX_REFERENCES_BYTES {
return Err(bad(format!(
"inflates past {MAX_REFERENCES_BYTES} bytes, more than any book cites"
)));
}
postcard::from_bytes(&inflated).map_err(|e| bad(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publishes_what_is_cited() {
let found = |passage: &str| FoundReference {
passage: passage.into(),
file: "epub/book.epub".into(),
cfi: "epubcfi(/6/14!/4/2,/1:5,/1:15)".into(),
chapter: None,
start: 0,
end: 9,
line: 1,
column: 1,
spine: 0,
line_text: String::new(),
};
let references: Vec<FoundReference> = [
"1Cor.9",
"Matt.10.8",
"Matt.10.8-Matt.10.10",
"Matt.10.8",
"Matt.9.2",
"Gen.1.1",
]
.into_iter()
.map(found)
.collect();
let cited = cited(&references);
let listed: Vec<(&str, u32)> = cited
.iter()
.map(|c| (c.passage.as_str(), c.count))
.collect();
assert_eq!(
listed,
[
("Gen.1.1", 1),
("Matt.9.2", 1),
("Matt.10.8", 2),
("Matt.10.8-Matt.10.10", 1),
("1Cor.9", 1)
]
);
let bytes = encode(&cited).unwrap();
assert!(bytes.starts_with(b"LXR1"));
assert_eq!(decode(&bytes).unwrap(), cited);
assert!(decode(b"LXR9").is_err());
assert!(decode(b"LXR1 not deflate").is_err());
let mut bomb = MAGIC.to_vec();
let mut deflate =
flate2::write::DeflateEncoder::new(&mut bomb, flate2::Compression::fast());
let zeros = vec![0u8; 1 << 20];
for _ in 0..=(MAX_REFERENCES_BYTES >> 20) {
deflate.write_all(&zeros).unwrap();
}
deflate.finish().unwrap();
assert!(bomb.len() < 1 << 20);
let error = decode(&bomb).unwrap_err().to_string();
assert!(error.contains("inflates past"), "{error}");
}
}