1use logoxide::{
15 LogoxideError, Result,
16 catalog::{Catalog, CatalogEntry, Download},
17 language,
18 manifest::{Direction, PackageKind},
19 storage::{MemoryStorage, Storage},
20};
21use serde::Deserialize;
22
23use crate::{Made, external, finish, import_id, package_id};
24
25pub const CATALOG: &str = "https://git.door43.org/api/v1/catalog/search?subject=Bible&stage=prod";
27
28pub fn archive_url(repository: &str, tag: &str) -> String {
30 format!("https://git.door43.org/{repository}/archive/{tag}.tar.gz")
31}
32
33pub fn is_catalog(location: &str) -> bool {
35 location
36 .to_ascii_lowercase()
37 .contains("door43.org/api/v1/catalog")
38}
39
40#[derive(Deserialize)]
42struct Search {
43 #[serde(default)]
44 data: Vec<Listed>,
45}
46
47#[derive(Deserialize)]
49struct Listed {
50 owner: String,
51 name: String,
52 #[serde(default)]
53 title: Option<String>,
54 #[serde(default)]
55 language: Option<String>,
56 #[serde(default)]
57 language_title: Option<String>,
58 #[serde(default)]
59 language_direction: Option<String>,
60 #[serde(default)]
61 subject: Option<String>,
62 #[serde(default)]
63 branch_or_tag_name: Option<String>,
64 #[serde(default)]
65 release: Option<Release>,
66 #[serde(default)]
67 tarbar_url: Option<String>,
68 #[serde(default)]
69 abbreviation: Option<String>,
70}
71
72#[derive(Deserialize)]
73struct Release {
74 #[serde(default)]
75 tag_name: Option<String>,
76}
77
78fn is_repository_name(name: &str) -> bool {
80 !name.is_empty()
81 && !name.starts_with(['.', '-'])
82 && (name.bytes()).all(|b| b.is_ascii_alphanumeric() || b"_-.".contains(&b))
83}
84
85pub fn catalog(text: &str) -> Result<Catalog> {
92 let search: Search = serde_json::from_str(text)
93 .map_err(|e| LogoxideError::Catalog(format!("Door43's catalog: {e}")))?;
94 let mut entries: Vec<CatalogEntry> = vec![];
95 for listed in search.data {
96 if listed.subject.as_deref() != Some("Bible") {
97 continue;
98 }
99 if !is_repository_name(&listed.owner) || !is_repository_name(&listed.name) {
100 continue;
101 }
102 let tag = (listed.release.and_then(|r| r.tag_name))
103 .or(listed.branch_or_tag_name)
104 .filter(|t| !t.is_empty() && !t.starts_with('-'));
105 let Some(tag) = tag else { continue };
106 let repository = format!("{}/{}", listed.owner, listed.name);
107 if (entries.iter()).any(|e| e.source.import_id() == Some(repository.as_str())) {
108 continue;
109 }
110 let mut urls = vec![archive_url(&repository, &tag)];
111 urls.extend(listed.tarbar_url.filter(|u| !urls.contains(u)));
112 let downloads = vec![Download {
113 name: String::from("archive"),
114 urls,
115 }];
116 let (language, language_name) = match listed.language.as_deref() {
117 Some(code) => {
118 let (tag, raw) = language::normalize_language(code);
119 (tag, raw.or(listed.language_title))
120 }
121 None => (String::from("und"), listed.language_title),
122 };
123 let direction = match listed.language_direction.as_deref() {
124 Some("rtl") => Direction::Rtl,
125 _ => Direction::Ltr,
126 };
127 let abbreviation = (listed.abbreviation.filter(|a| !a.is_empty()))
129 .unwrap_or_else(|| {
130 let name = &listed.name;
131 name.split_once('_')
132 .map_or(name.as_str(), |(_, a)| a)
133 .to_string()
134 })
135 .to_uppercase();
136 entries.push(CatalogEntry {
137 id: format!("door43-{}", package_id(&repository.replace('/', "-"))),
138 name: listed.title.unwrap_or_else(|| listed.name.clone()),
139 abbreviation: Some(abbreviation),
140 aliases: vec![],
141 language,
142 language_name,
143 script: None,
144 description: None,
145 license: None,
146 rights: Default::default(),
147 copyright: None,
148 version: Some(tag),
149 kind: PackageKind::Bible,
150 direction,
151 source: external("door43", &repository, true, downloads),
152 path: None,
153 releases: vec![],
154 keywords: vec![],
155 categories: vec![],
156 about_books: vec![],
157 work: None,
158 edition: None,
159 edition_year: None,
160 keys: None,
161 });
162 }
163 if entries.is_empty() {
164 return Err(LogoxideError::Catalog(String::from(
165 "Door43's catalog lists no Bibles (has its API changed?)",
166 )));
167 }
168 entries.sort_by(|a, b| a.id.cmp(&b.id));
169 logoxide::catalog::known_works(&mut entries);
170 Ok(Catalog {
171 name: String::from("Door43"),
172 entries,
173 works: vec![],
174 warnings: vec![],
175 })
176}
177
178#[derive(Debug, Default, Deserialize)]
180pub struct ResourceManifest {
181 #[serde(default)]
182 pub dublin_core: DublinCore,
183}
184
185#[derive(Debug, Default, Deserialize)]
187pub struct DublinCore {
188 #[serde(default)]
189 pub title: Option<String>,
190 #[serde(default)]
191 pub description: Option<String>,
192 #[serde(default)]
193 pub language: Option<RcLanguage>,
194 #[serde(default)]
196 pub rights: Option<String>,
197 #[serde(default)]
199 pub version: Option<serde_yaml::Value>,
200 #[serde(default)]
201 pub publisher: Option<String>,
202}
203
204#[derive(Debug, Default, Deserialize)]
206pub struct RcLanguage {
207 #[serde(default)]
208 pub identifier: Option<String>,
209 #[serde(default)]
210 pub title: Option<String>,
211 #[serde(default)]
212 pub direction: Option<String>,
213}
214
215impl DublinCore {
216 pub fn version(&self) -> Option<String> {
218 let version = match self.version.as_ref()? {
219 serde_yaml::Value::String(s) => s.trim().to_string(),
220 serde_yaml::Value::Number(n) => n.to_string(),
221 _ => return None,
222 };
223 (!version.is_empty()).then_some(version)
224 }
225}
226
227fn rights_license(rights: &str) -> Option<String> {
230 let spdx = rights.split_whitespace().collect::<Vec<_>>().join("-");
231 match logoxide::license::check_license(&spdx) {
232 Ok(()) if spdx.to_ascii_uppercase().starts_with("CC") => Some(spdx),
233 _ => crate::license::license_from_text(rights),
234 }
235}
236
237pub fn import(entry: &CatalogEntry, archive: &[u8], dest: &dyn Storage) -> Result<()> {
245 let repository = import_id(entry, "door43", "Door43")?;
246 let unpacked = MemoryStorage::new();
247 logoxide::net::archive::unpack(archive, &unpacked)?;
248 let manifest = unpacked.read_string("manifest.yaml").map_err(|_| {
249 LogoxideError::Import(format!(
250 "{repository}: no manifest.yaml (is it a resource container?)"
251 ))
252 })?;
253 let manifest: ResourceManifest = serde_yaml::from_str(&manifest)
254 .map_err(|e| LogoxideError::Import(format!("{repository}: manifest.yaml: {e}")))?;
255 let core = manifest.dublin_core;
256 let staged = MemoryStorage::new();
257 let mut license_file = None;
258 for path in unpacked.walk("")? {
259 let name = path.rsplit('/').next().unwrap_or(&path);
260 let lower = name.to_ascii_lowercase();
261 if lower.ends_with(".usfm") {
262 staged.write(&format!("usfm/{name}"), &unpacked.read(&path)?)?;
263 } else if path == "LICENSE.md" {
264 staged.write("LICENSE.md", &unpacked.read(&path)?)?;
265 license_file = Some(String::from("LICENSE.md"));
266 }
267 }
268 let mut entry = entry.clone();
269 if let Some(title) = core
270 .title
271 .as_deref()
272 .map(str::trim)
273 .filter(|t| !t.is_empty())
274 && title != entry.name
275 {
276 let listed = std::mem::replace(&mut entry.name, title.to_string());
277 entry.aliases.push(listed);
278 }
279 if let Some(description) = core.description.as_deref().map(str::trim) {
280 entry.description = (!description.is_empty()).then(|| description.to_string());
281 }
282 if let Some(rc) = core.language.as_ref() {
283 if let Some(code) = rc.identifier.as_deref() {
284 let (tag, raw) = language::normalize_language(code);
285 entry.language = tag;
286 entry.language_name = raw.or_else(|| rc.title.clone()).or(entry.language_name);
287 }
288 match rc.direction.as_deref() {
289 Some("rtl") => entry.direction = Direction::Rtl,
290 Some("ltr") => entry.direction = Direction::Ltr,
291 _ => {}
292 }
293 }
294 entry.license = (core.rights.as_deref())
295 .and_then(rights_license)
296 .or(entry.license);
297 entry.version = core.version().or(entry.version);
298 let made = Made {
299 format: "usfm",
300 license_file,
301 publisher: "Door43",
302 homepage: format!("https://git.door43.org/{repository}"),
303 versification: None,
304 keys: None,
305 };
306 finish(&entry, made, staged, dest)
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use logoxide::package::{Features, Package};
313
314 const SEARCH: &str = include_str!("../tests/fixtures/door43/catalog-search.json");
316
317 const MANIFEST: &str = r#"---
318dublin_core:
319 conformsto: 'rc0.2'
320 contributor:
321 - 'A Contributor'
322 creator: 'Someone Or Organization'
323 description: 'One or two sentence description of the resource.'
324 format: 'text/usfm'
325 identifier: 'irv'
326 issued: '2019-12-17'
327 language:
328 identifier: 'hi'
329 title: 'हिन्दी'
330 direction: 'ltr'
331 modified: '2019-12-22T12:01:30-05:00'
332 publisher: 'Bridge Connectivity Solutions'
333 relation:
334 - 'hi/tn'
335 rights: 'CC BY-SA 4.0'
336 subject: 'Bible'
337 title: 'Indian Revised Version'
338 type: 'bundle'
339 version: 2
340checking:
341 checking_level: '3'
342projects:
343 -
344 categories:
345 - 'bible-nt'
346 identifier: 'jhn'
347 path: './44-JHN.usfm'
348 sort: 43
349 title: 'यूहन्ना'
350 versification: 'ufw'
351"#;
352
353 fn archive(files: &[(&str, &str)]) -> Vec<u8> {
355 let mut tar = tar::Builder::new(flate2::write::GzEncoder::new(
356 Vec::new(),
357 flate2::Compression::fast(),
358 ));
359 for (path, text) in files {
360 let mut header = tar::Header::new_gnu();
361 header.set_size(text.len() as u64);
362 header.set_mode(0o644);
363 header.set_cksum();
364 tar.append_data(&mut header, format!("hi_irv/{path}"), text.as_bytes())
365 .unwrap();
366 }
367 tar.into_inner().unwrap().finish().unwrap()
368 }
369
370 #[test]
371 fn the_catalog() {
372 assert!(is_catalog(CATALOG));
373 let catalog = crate::catalog::parse_text(CATALOG, SEARCH).unwrap();
374 assert_eq!(catalog.name, "Door43");
375 let ids: Vec<&str> = catalog.entries.iter().map(|e| e.id.as_str()).collect();
377 assert_eq!(
378 ids,
379 [
380 "door43-door43-catalog-ar_avd",
381 "door43-door43-catalog-hi_irv",
382 "door43-door43-catalog-ur-deva_irv"
383 ]
384 );
385 let irv = catalog.find_import("door43:Door43-Catalog/hi_irv").unwrap();
386 assert_eq!((irv.short_name(), irv.language.as_str()), ("IRV", "hi"));
387 assert_eq!(irv.version.as_deref(), Some("v2"));
388 assert!(irv.source.versions_track_updates());
389 assert_eq!(
390 irv.source.downloads()[0].urls,
391 ["https://git.door43.org/Door43-Catalog/hi_irv/archive/v2.tar.gz"]
392 );
393 let avd = catalog.find_import("door43:Door43-Catalog/ar_avd").unwrap();
394 assert_eq!(avd.direction, Direction::Rtl);
395 assert_eq!(crate::catalog::publisher_name(&avd.source), Some("Door43"));
396 assert!(crate::catalog::parse_text(CATALOG, "{\"ok\":true,\"data\":[]}").is_err());
397 }
398
399 #[test]
400 fn imports_a_release() {
401 let catalog = catalog(SEARCH).unwrap();
402 let entry = catalog.find_import("door43:Door43-Catalog/hi_irv").unwrap();
403 let usfm = "\\id JHN\n\\h यूहन्ना\n\\c 1\n\\p\n\\v 1 आदि में वचन था।\n\\v 2 यही आदि में परमेश्वर के साथ था।\n";
404 let bytes = archive(&[
405 ("manifest.yaml", MANIFEST),
406 ("LICENSE.md", "# License\n\nCC BY-SA 4.0\n"),
407 ("44-JHN.usfm", usfm),
408 ("media.yaml", "projects: []\n"),
409 ]);
410 let dest = MemoryStorage::new();
411 crate::tests::build_twice(entry, &[("archive".into(), bytes)], &dest);
412 assert_eq!(
413 dest.walk("").unwrap(),
414 ["LICENSE.md", "logoxide.toml", "usfm/44-JHN.usfm"]
415 );
416 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
417 let info = package.info();
418 assert_eq!(info.name, "Indian Revised Version");
419 assert_eq!(info.aliases, ["Indian Revised Version (IRV) Hindi - 2019"]);
420 assert_eq!(info.license.as_deref(), Some("CC-BY-SA-4.0"));
421 assert_eq!(info.license_file.as_deref(), Some("LICENSE.md"));
422 assert_eq!(info.version.to_string(), "1.0.0");
423 let upstream = info.upstream.as_ref().unwrap();
424 assert_eq!(
425 (upstream.import.as_deref(), upstream.version.as_deref()),
426 (Some("door43:Door43-Catalog/hi_irv"), Some("2"))
427 );
428 assert_eq!(
429 upstream.url.as_deref(),
430 Some("https://git.door43.org/Door43-Catalog/hi_irv/archive/v2.tar.gz")
431 );
432 assert_eq!(package.books().len(), 1);
433 let bytes = archive(&[("44-JHN.usfm", usfm)]);
435 let error = import(entry, &bytes, &MemoryStorage::new()).unwrap_err();
436 assert!(error.to_string().contains("manifest.yaml"), "{error}");
437 let bare = MANIFEST.replace(" version: 2\n", "");
439 let dest = MemoryStorage::new();
440 import(
441 entry,
442 &archive(&[("manifest.yaml", &bare), ("44-JHN.usfm", usfm)]),
443 &dest,
444 )
445 .unwrap();
446 let package = Package::open(std::sync::Arc::new(dest), Features::default()).unwrap();
447 let upstream = package.info().upstream.clone().unwrap();
448 assert_eq!(upstream.version.as_deref(), Some("v2"));
449 }
450
451 #[test]
452 fn rights_as_licenses() {
453 assert_eq!(
454 rights_license("CC BY-SA 4.0").as_deref(),
455 Some("CC-BY-SA-4.0")
456 );
457 assert_eq!(rights_license("CC BY 4.0").as_deref(), Some("CC-BY-4.0"));
458 assert_eq!(
459 rights_license("Public Domain").as_deref(),
460 Some("LicenseRef-PublicDomain")
461 );
462 assert_eq!(rights_license("All rights reserved"), None);
463 }
464}