1pub mod burrito;
36pub mod catalog;
37pub mod ccel;
38pub mod door43;
39pub mod ebible;
40pub mod fetch;
41pub mod license;
42pub mod sword;
43pub mod update;
44
45use logoxide::{
46 LogoxideError, Result,
47 catalog::{Catalog, CatalogEntry, CatalogSource, Download},
48 manifest::{Format, FormatEntry, Keys, MANIFEST, Manifest, Package as Info, Upstream},
49 package::{Features, Package},
50 storage::{MemoryStorage, Storage},
51};
52
53pub use update::{
54 ImportUpdate, apply_import, compare_import, compare_publisher_versions, import_is_current,
55 import_removals, set_version,
56};
57
58pub const PREFIXES: [&str; 7] = [
60 "ebible", "fetch", "ccel", "sword", "door43", "burrito", "dbl",
61];
62
63pub const IMPORT_VERSION: semver::Version = semver::Version::new(1, 0, 0);
65
66pub fn unzip(bytes: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
68 Ok(logoxide_text::archive::unzip(bytes)?)
69}
70
71pub fn downloads(entry: &CatalogEntry) -> Result<Vec<Download>> {
77 match &entry.source {
78 CatalogSource::External { downloads, .. } => Ok(downloads.clone()),
79 CatalogSource::Git { .. } | CatalogSource::Registry { .. } => Err(LogoxideError::Import(
80 format!("{} isn't imported: add it", entry.id),
81 )),
82 }
83}
84
85pub fn build(entry: &CatalogEntry, files: &[(String, Vec<u8>)], dest: &dyn Storage) -> Result<()> {
88 let named = |name: &str| {
89 (files.iter())
90 .find(|(n, _)| n == name)
91 .map(|(_, bytes)| bytes.as_slice())
92 .ok_or_else(|| LogoxideError::Import(format!("{}: {name} wasn't downloaded", entry.id)))
93 };
94 match entry.source.prefix() {
95 Some("ebible") => ebible::import(entry, named("zip")?, dest),
96 Some("fetch") => fetch::import(entry, files, dest),
97 Some("ccel") => ccel::import(entry, named("thml")?, dest),
98 Some("sword") => sword::import(entry, named("zip")?, dest),
99 Some("door43") => door43::import(entry, named("archive")?, dest),
100 Some("burrito" | "dbl") => burrito::import(entry, files, dest),
101 Some(other) => Err(LogoxideError::Import(format!(
102 "{}: no import reads `{other}:` downloads",
103 entry.id
104 ))),
105 None => Err(LogoxideError::Import(format!(
106 "{} isn't imported: add it",
107 entry.id
108 ))),
109 }
110}
111
112pub fn upstream(entry: &CatalogEntry) -> Option<Upstream> {
119 let CatalogSource::External {
120 import, upstream, ..
121 } = &entry.source
122 else {
123 return None;
124 };
125 Some(Upstream {
126 url: upstream.clone(),
127 import: Some(import.clone()),
128 version: entry.version.clone(),
129 converted: entry.source.prefix() == Some("sword"),
132 note: None,
133 })
134}
135
136pub fn named_catalog(import: &str) -> Option<Catalog> {
142 ccel::named_catalog(import).or_else(|| burrito::named_catalog(import))
143}
144
145pub(crate) fn external(
148 prefix: &str,
149 id: &str,
150 tracks_versions: bool,
151 downloads: Vec<Download>,
152) -> CatalogSource {
153 CatalogSource::External {
154 import: format!("{prefix}:{id}"),
155 upstream: (downloads.first()).and_then(|d| d.urls.first().cloned()),
156 tracks_versions,
157 downloads,
158 }
159}
160
161pub(crate) fn package_id(id: &str) -> String {
164 id.to_lowercase()
165 .chars()
166 .map(|c| match c {
167 'a'..='z' | '0'..='9' | '-' | '_' => c,
168 _ => '-',
169 })
170 .collect()
171}
172
173pub(crate) fn import_id<'a>(
176 entry: &'a CatalogEntry,
177 prefix: &str,
178 publisher: &str,
179) -> Result<&'a str> {
180 match entry.source.prefix() == Some(prefix) {
181 true => entry
182 .source
183 .import_id()
184 .ok_or_else(|| LogoxideError::Import(format!("{}: no import id", entry.id))),
185 false => Err(LogoxideError::Import(format!(
186 "{} isn't from {publisher}",
187 entry.id
188 ))),
189 }
190}
191
192pub(crate) struct Made {
194 pub(crate) format: &'static str,
196 pub(crate) license_file: Option<String>,
197 pub(crate) publisher: &'static str,
199 pub(crate) publisher_name: Option<String>,
201 pub(crate) homepage: String,
203 pub(crate) versification: Option<String>,
205 pub(crate) keys: Option<Keys>,
207 pub(crate) note: Option<String>,
209}
210
211pub(crate) fn finish(
214 entry: &CatalogEntry,
215 made: Made,
216 staged: MemoryStorage,
217 dest: &dyn Storage,
218) -> Result<()> {
219 use logoxide::language::{script_direction, script_of_tag};
220 let script = (entry.script.clone()).or_else(|| script_of_tag(&entry.language));
222 let implied = script_direction(script.as_deref());
223 let info = Info {
224 id: entry.id.clone(),
225 name: entry.name.clone(),
226 abbreviation: entry.abbreviation.clone(),
227 authors: vec![],
228 description: entry.description.clone(),
229 aliases: entry.aliases.clone(),
230 version: IMPORT_VERSION,
231 kind: entry.kind,
232 language: entry.language.clone(),
233 language_name: entry.language_name.clone(),
234 script: entry.script.clone(),
235 direction: (entry.direction != implied).then_some(entry.direction),
236 license: (entry.license.clone()).map(|license| {
238 match logoxide::license::check_license(&license) {
239 Ok(()) => license,
240 Err(_) => format!("LicenseRef-{}", made.publisher.replace(' ', "-")),
241 }
242 }),
243 rights: (!entry.rights.is_empty()).then_some(entry.rights),
244 license_file: made.license_file,
245 publisher: Some(
246 made.publisher_name
247 .unwrap_or_else(|| made.publisher.to_string()),
248 ),
249 homepage: (!made.homepage.is_empty()).then_some(made.homepage),
250 repository: None,
251 documentation: None,
252 readme: None,
253 keywords: entry.keywords.clone(),
254 categories: entry.categories.clone(),
255 about_books: entry.about_books.clone(),
256 work: (entry.work.clone()).or_else(|| logoxide::works::of_source(&entry.source)),
258 edition: entry.edition.clone(),
259 edition_year: entry.edition_year,
260 upstream: upstream(entry).map(|u| Upstream {
261 note: made.note,
262 ..u
263 }),
264 versification: made.versification.unwrap_or_else(|| String::from("own")),
265 maps_to: None,
266 keys: made.keys,
267 include: vec![],
268 };
269 let folder = match Format::from_name(made.format) {
271 Some(Format::Dictionary) => "entries",
272 _ => made.format,
273 };
274 let manifest = Manifest {
275 package: Some(info),
276 formats: [(
277 made.format.to_string(),
278 FormatEntry {
279 path: format!("{folder}/"),
280 feature: None,
281 },
282 )]
283 .into(),
284 ..Manifest::default()
285 };
286 let text = toml::to_string(&manifest).map_err(|e| LogoxideError::Import(e.to_string()))?;
287 let header = format!("# Imported from {} by logoxide\n", made.publisher);
288 staged.write(MANIFEST, format!("{header}{text}").as_bytes())?;
289 let staged = std::sync::Arc::new(staged);
290 let package = Package::open(staged.clone(), Features::default())?;
291 let verses = (package.text_format()).is_some_and(|(f, _)| f.is_verse_text());
292 let book = entry.kind == logoxide::manifest::PackageKind::Book;
294 if verses && package.books().is_empty() {
295 return Err(LogoxideError::Import(format!("{}: no books", entry.id)));
296 }
297 if !verses && !book && package.source().keys().is_empty() {
298 return Err(LogoxideError::Import(format!("{}: no entries", entry.id)));
299 }
300 for path in staged.walk("")? {
301 dest.write(&path, &staged.read(&path)?)?;
302 }
303 Ok(())
304}
305
306#[cfg(test)]
307pub(crate) mod tests {
308 use std::io::Write;
309
310 pub(crate) fn zip(files: &[(&str, &str)]) -> Vec<u8> {
312 zip_bytes(
313 &(files.iter())
314 .map(|(path, text)| (*path, text.as_bytes()))
315 .collect::<Vec<_>>(),
316 )
317 }
318
319 pub(crate) fn zip_bytes(files: &[(&str, &[u8])]) -> Vec<u8> {
320 let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
321 for (path, bytes) in files {
322 writer
323 .start_file(*path, zip::write::SimpleFileOptions::default())
324 .unwrap();
325 writer.write_all(bytes).unwrap();
326 }
327 writer.finish().unwrap().into_inner()
328 }
329
330 pub(crate) fn build_twice(
335 entry: &logoxide::catalog::CatalogEntry,
336 files: &[(String, Vec<u8>)],
337 dest: &dyn logoxide::storage::Storage,
338 ) {
339 super::build(entry, files, dest).unwrap();
340 use logoxide::storage::Storage;
341 let again = super::MemoryStorage::new();
342 super::build(entry, files, &again).unwrap();
343 assert_eq!(dest.walk("").unwrap(), again.walk("").unwrap());
344 for path in again.walk("").unwrap() {
345 assert_eq!(
346 dest.read(&path).unwrap(),
347 again.read(&path).unwrap(),
348 "{path}"
349 );
350 }
351 }
352
353 #[test]
354 fn what_isnt_imported_is_said() {
355 let catalog = logoxide::catalog::Catalog::parse_toml(
356 "[[package]]\nid = \"kjv\"\nname = \"KJV\"\nabbreviation = \"KJV\"\nlanguage = \"en\"\ngit = \"https://example.org/kjv\"\n",
357 )
358 .unwrap();
359 let entry = &catalog.entries[0];
360 assert!(super::downloads(entry).is_err());
361 assert!(super::build(entry, &[], &super::MemoryStorage::new()).is_err());
362 assert_eq!(super::upstream(entry), None);
363 assert_eq!(super::package_id("eng-ASV 1901"), "eng-asv-1901");
364 }
365}