1use std::{mem, path::Path, time::Duration};
2
3use crate::common::PmcId;
4use crate::config::ClientConfig;
5use crate::error::{ParseError, PubMedError, Result};
6use crate::pmc::common;
7use crate::pmc::extracted::ExtractedFigure;
8use crate::pmc::parser::parse_pmc_xml;
9use crate::rate_limit::RateLimiter;
10use crate::request::RequestExecutor;
11#[cfg(not(target_arch = "wasm32"))]
12use crate::request::fetch_with_retry;
13use crate::tls::install_default_crypto_provider;
14use pubmed_parser::pmc::{Figure, PmcArticle, Section};
15use reqwest::Client;
16#[cfg(not(target_arch = "wasm32"))]
17use reqwest::Response;
18use tracing::debug;
19
20#[cfg(not(target_arch = "wasm32"))]
21use futures_util::{StreamExt, TryStreamExt, stream};
22#[cfg(not(target_arch = "wasm32"))]
23use tokio::{fs as tokio_fs, task};
24
25#[derive(Clone)]
32pub struct PmcCloudClient {
33 client: Client,
34 rate_limiter: RateLimiter,
35 pub(crate) config: ClientConfig,
36}
37
38impl PmcCloudClient {
39 pub fn new(config: ClientConfig) -> Self {
41 let rate_limiter = config.create_rate_limiter();
42
43 install_default_crypto_provider();
45
46 #[allow(clippy::expect_used)]
47 let client = {
48 #[cfg(not(target_arch = "wasm32"))]
49 {
50 Client::builder()
51 .user_agent(config.effective_user_agent())
52 .timeout(Duration::from_secs(config.timeout.as_secs()))
53 .build()
54 .expect("Failed to create HTTP client")
55 }
56
57 #[cfg(target_arch = "wasm32")]
58 {
59 Client::builder()
60 .user_agent(config.effective_user_agent())
61 .build()
62 .expect("Failed to create HTTP client")
63 }
64 };
65
66 Self {
67 client,
68 rate_limiter,
69 config,
70 }
71 }
72
73 pub(crate) fn with_shared(
77 client: Client,
78 rate_limiter: RateLimiter,
79 config: ClientConfig,
80 ) -> Self {
81 Self {
82 client,
83 rate_limiter,
84 config,
85 }
86 }
87
88 #[cfg(not(target_arch = "wasm32"))]
132 pub async fn download_files<P: AsRef<Path>>(
133 &self,
134 pmcid: &str,
135 output_dir: P,
136 ) -> Result<Vec<String>> {
137 let pmc_id = PmcId::parse(pmcid)?;
138 let normalized_pmcid = pmc_id.as_str();
139
140 let output_path = output_dir.as_ref();
141 tokio_fs::create_dir_all(output_path)
142 .await
143 .map_err(|e| ParseError::IoError {
144 message: format!("Failed to create output directory: {}", e),
145 })?;
146
147 let files = self
148 .download_cloud_files(&normalized_pmcid, output_path)
149 .await?;
150
151 if files.is_empty() {
152 return Err(ParseError::PmcNotAvailable {
153 id: pmcid.to_string(),
154 }
155 .into());
156 }
157
158 Ok(files)
159 }
160
161 #[cfg(not(target_arch = "wasm32"))]
168 async fn download_cloud_files(
169 &self,
170 normalized_pmcid: &str,
171 output_dir: &Path,
172 ) -> Result<Vec<String>> {
173 let keys = self.list_cloud_object_keys(normalized_pmcid).await?;
174 if keys.is_empty() {
175 return Ok(Vec::new());
176 }
177
178 let base_url = self
179 .config
180 .effective_oa_cloud_base_url()
181 .trim_end_matches('/');
182 let concurrency = self.config.effective_oa_download_concurrency();
183
184 let downloaded = stream::iter(keys)
189 .map(|key| async move {
190 let Some(file_name) = key.rsplit('/').next().filter(|s| !s.is_empty()) else {
193 return Ok::<Option<String>, PubMedError>(None);
194 };
195
196 let url = format!("{}/{}", base_url, key);
197 let response = self.s3_get(&url).await?;
198 let bytes = response.bytes().await.map_err(PubMedError::from)?;
199
200 let output_path = output_dir.join(file_name);
201 tokio_fs::write(&output_path, &bytes)
202 .await
203 .map_err(|e| ParseError::IoError {
204 message: format!("Failed to write cloud file {}: {}", file_name, e),
205 })?;
206
207 debug!("Downloaded cloud file: {}", output_path.display());
208 Ok(Some(output_path.to_string_lossy().to_string()))
209 })
210 .buffered(concurrency)
211 .try_filter_map(|opt| async move { Ok(opt) })
212 .try_collect::<Vec<String>>()
213 .await?;
214
215 Ok(downloaded)
216 }
217
218 #[cfg(not(target_arch = "wasm32"))]
225 async fn list_cloud_object_keys(&self, normalized_pmcid: &str) -> Result<Vec<String>> {
226 let base_url = self.config.effective_oa_cloud_base_url();
227 let url = format!(
230 "{}/?list-type=2&prefix={}.",
231 base_url.trim_end_matches('/'),
232 normalized_pmcid
233 );
234
235 debug!("Listing PMC OA Cloud objects: {}", url);
236 let response = self.s3_get(&url).await?;
237 let body = response.text().await?;
238
239 let keys = Self::parse_cloud_listing(&body)?;
240 Ok(Self::select_latest_version_keys(keys))
241 }
242
243 #[cfg(not(target_arch = "wasm32"))]
245 fn parse_cloud_listing(xml_content: &str) -> Result<Vec<String>> {
246 use quick_xml::Reader;
247 use quick_xml::events::Event;
248
249 use quick_xml::escape::resolve_predefined_entity;
250
251 let mut reader = Reader::from_str(xml_content);
252 reader.config_mut().trim_text(true);
253
254 let mut buf = Vec::new();
255 let mut keys = Vec::new();
256 let mut in_key = false;
257 let mut key = String::new();
260
261 loop {
262 match reader.read_event_into(&mut buf) {
263 Ok(Event::Start(ref e)) if e.name().as_ref() == b"Key" => {
264 in_key = true;
265 key.clear();
266 }
267 Ok(Event::End(ref e)) if e.name().as_ref() == b"Key" => {
268 in_key = false;
269 if !key.is_empty() && !key.ends_with('/') {
271 keys.push(mem::take(&mut key));
272 }
273 }
274 Ok(Event::Text(ref e)) if in_key => {
275 let text = e.decode().map_err(|err| {
276 ParseError::XmlError(format!("Invalid UTF-8 in S3 Key: {}", err))
277 })?;
278 key.push_str(&text);
279 }
280 Ok(Event::GeneralRef(ref e)) if in_key => {
281 let char_ref = e.resolve_char_ref().map_err(|err| {
282 ParseError::XmlError(format!("Invalid reference in S3 Key: {}", err))
283 })?;
284 if let Some(ch) = char_ref {
285 key.push(ch);
286 } else {
287 let name = e.decode().map_err(|err| {
288 ParseError::XmlError(format!("Invalid UTF-8 in S3 Key: {}", err))
289 })?;
290 if let Some(text) = resolve_predefined_entity(&name) {
291 key.push_str(text);
292 }
293 }
294 }
295 Ok(Event::Eof) => break,
296 Err(e) => {
297 return Err(
298 ParseError::XmlError(format!("Failed to parse S3 listing: {}", e)).into(),
299 );
300 }
301 _ => {}
302 }
303 buf.clear();
304 }
305
306 Ok(keys)
307 }
308
309 #[cfg(not(target_arch = "wasm32"))]
315 fn select_latest_version_keys(keys: Vec<String>) -> Vec<String> {
316 fn version_of(key: &str) -> Option<u32> {
317 let folder = key.split('/').next()?;
318 folder.rsplit('.').next()?.parse::<u32>().ok()
319 }
320
321 let Some(latest) = keys.iter().filter_map(|k| version_of(k)).max() else {
322 return keys;
323 };
324
325 keys.into_iter()
326 .filter(|k| version_of(k) == Some(latest))
327 .collect()
328 }
329
330 #[cfg(not(target_arch = "wasm32"))]
370 pub async fn extract_figures_with_captions<P: AsRef<Path>>(
371 &self,
372 pmcid: &str,
373 output_dir: P,
374 ) -> Result<Vec<ExtractedFigure>> {
375 let normalized_pmcid = common::normalize_pmcid(pmcid);
376
377 let output_path = output_dir.as_ref();
378 tokio_fs::create_dir_all(output_path)
379 .await
380 .map_err(|e| ParseError::IoError {
381 message: format!("Failed to create output directory: {}", e),
382 })?;
383
384 let extracted_files = self.download_files(&normalized_pmcid, &output_dir).await?;
388
389 let full_text = self
390 .parse_article_xml(&normalized_pmcid, &extracted_files)
391 .await?;
392
393 let figures = self
394 .match_figures_with_files(&full_text, &extracted_files, &output_dir)
395 .await?;
396
397 Ok(figures)
398 }
399
400 #[cfg(not(target_arch = "wasm32"))]
408 async fn parse_article_xml(
409 &self,
410 normalized_pmcid: &str,
411 extracted_files: &[String],
412 ) -> Result<PmcArticle> {
413 if let Some(xml_path) = Self::find_downloaded_xml(extracted_files, normalized_pmcid) {
414 let xml_content =
415 tokio_fs::read_to_string(&xml_path)
416 .await
417 .map_err(|e| ParseError::IoError {
418 message: format!("Failed to read downloaded XML {}: {}", xml_path, e),
419 })?;
420 return Ok(parse_pmc_xml(&xml_content, normalized_pmcid)?);
421 }
422
423 debug!(
424 pmcid = %normalized_pmcid,
425 "OA Cloud package had no XML; falling back to eutils fetch"
426 );
427 let xml_content = common::fetch_pmc_xml(
428 &self.executor(),
429 self.config.effective_base_url(),
430 normalized_pmcid,
431 )
432 .await?;
433 Ok(parse_pmc_xml(&xml_content, normalized_pmcid)?)
434 }
435
436 #[cfg(not(target_arch = "wasm32"))]
441 fn find_downloaded_xml(extracted_files: &[String], normalized_pmcid: &str) -> Option<String> {
442 let pmcid_lower = normalized_pmcid.to_lowercase();
443 extracted_files
444 .iter()
445 .find(|path| {
446 let name = Path::new(path)
447 .file_name()
448 .map(|n| n.to_string_lossy().to_lowercase())
449 .unwrap_or_default();
450 name.ends_with(".xml") && name.contains(&pmcid_lower)
451 })
452 .cloned()
453 }
454
455 #[cfg(not(target_arch = "wasm32"))]
457 async fn match_figures_with_files<P: AsRef<Path>>(
458 &self,
459 full_text: &PmcArticle,
460 extracted_files: &[String],
461 output_dir: P,
462 ) -> Result<Vec<ExtractedFigure>> {
463 let output_path = output_dir.as_ref();
464 let mut matched_figures = Vec::new();
465
466 let mut all_figures = Vec::new();
467 for section in full_text.sections() {
468 Self::collect_figures_recursive(section, &mut all_figures);
469 }
470
471 let image_extensions = [
472 "jpg", "jpeg", "png", "gif", "tiff", "tif", "svg", "eps", "pdf",
473 ];
474
475 for figure in all_figures {
476 let matching_file =
477 Self::find_matching_file(&figure, extracted_files, &image_extensions);
478
479 if let Some(file_path) = matching_file {
480 let absolute_path =
481 if file_path.starts_with(&output_path.to_string_lossy().to_string()) {
482 file_path.clone()
483 } else {
484 output_path.join(&file_path).to_string_lossy().to_string()
485 };
486
487 let file_size = tokio_fs::metadata(&absolute_path)
488 .await
489 .map(|m| m.len())
490 .ok();
491
492 let dimensions = Self::get_image_dimensions(&absolute_path).await;
493
494 matched_figures.push(ExtractedFigure {
495 figure: figure.clone(),
496 extracted_file_path: absolute_path,
497 file_size,
498 dimensions,
499 });
500 }
501 }
502
503 Ok(matched_figures)
504 }
505
506 #[cfg(not(target_arch = "wasm32"))]
508 fn collect_figures_recursive(section: &Section, figures: &mut Vec<Figure>) {
509 figures.extend(section.figures.clone());
510 for subsection in §ion.subsections {
511 Self::collect_figures_recursive(subsection, figures);
512 }
513 }
514
515 #[cfg(not(target_arch = "wasm32"))]
522 pub fn find_matching_file(
523 figure: &Figure,
524 extracted_files: &[String],
525 image_extensions: &[&str],
526 ) -> Option<String> {
527 if let Some(file_name) = &figure.graphic_href
530 && let Some(matched) =
531 Self::find_first_file(extracted_files, false, image_extensions, |filename| {
532 filename.contains(file_name.as_str())
533 })
534 {
535 return Some(matched);
536 }
537
538 let figure_id_lower = figure.id.to_lowercase();
540 if let Some(matched) =
541 Self::find_first_file(extracted_files, true, image_extensions, |filename| {
542 filename.to_lowercase().contains(&figure_id_lower)
543 })
544 {
545 return Some(matched);
546 }
547
548 if let Some(label) = &figure.label {
550 let label_clean = label.to_lowercase().replace([' ', '.'], "");
551 if let Some(matched) =
552 Self::find_first_file(extracted_files, true, image_extensions, |filename| {
553 filename.to_lowercase().contains(&label_clean)
554 })
555 {
556 return Some(matched);
557 }
558 }
559
560 None
561 }
562
563 #[cfg(not(target_arch = "wasm32"))]
569 fn find_first_file(
570 extracted_files: &[String],
571 require_image_ext: bool,
572 image_extensions: &[&str],
573 predicate: impl Fn(&str) -> bool,
574 ) -> Option<String> {
575 for file_path in extracted_files {
576 let path = Path::new(file_path);
577 let Some(filename) = path.file_name() else {
578 continue;
579 };
580 if !predicate(&filename.to_string_lossy()) {
581 continue;
582 }
583 if require_image_ext && !Self::has_image_extension(path, image_extensions) {
584 continue;
585 }
586 return Some(file_path.clone());
587 }
588 None
589 }
590
591 #[cfg(not(target_arch = "wasm32"))]
593 fn has_image_extension(path: &Path, image_extensions: &[&str]) -> bool {
594 path.extension()
595 .map(|ext| image_extensions.contains(&ext.to_string_lossy().to_lowercase().as_str()))
596 .unwrap_or(false)
597 }
598
599 #[cfg(not(target_arch = "wasm32"))]
601 async fn get_image_dimensions(file_path: &str) -> Option<(u32, u32)> {
602 task::spawn_blocking({
603 let file_path = file_path.to_string();
604 move || {
605 image::open(&file_path)
606 .ok()
607 .map(|img| (img.width(), img.height()))
608 }
609 })
610 .await
611 .ok()
612 .flatten()
613 }
614
615 fn executor(&self) -> RequestExecutor<'_> {
616 RequestExecutor::new(&self.client, &self.rate_limiter, &self.config)
617 }
618
619 #[cfg(not(target_arch = "wasm32"))]
626 async fn s3_get(&self, url: &str) -> Result<Response> {
627 debug!("Making PMC OA Cloud (S3) request to: {url}");
628 fetch_with_retry(
629 || self.client.get(url),
630 &self.config.retry_config,
631 None,
632 "PMC OA Cloud request",
633 )
634 .await
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
643 fn test_normalize_pmcid() {
644 assert_eq!(common::normalize_pmcid("1234567"), "PMC1234567");
645 assert_eq!(common::normalize_pmcid("PMC1234567"), "PMC1234567");
646 }
647
648 #[test]
649 fn test_client_creation() {
650 let config = ClientConfig::new();
651 let _client = PmcCloudClient::new(config);
652 }
653
654 #[test]
655 fn test_with_shared_creation() {
656 let config = ClientConfig::new();
657 let rate_limiter = config.create_rate_limiter();
658 let client = Client::new();
659 let _cloud_client = PmcCloudClient::with_shared(client, rate_limiter, config);
660 }
661
662 #[cfg(not(target_arch = "wasm32"))]
663 #[test]
664 fn test_parse_cloud_listing_extracts_keys() {
665 let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
666<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/"><Name>pmc-oa-opendata</Name><Prefix>PMC7906746.</Prefix><KeyCount>5</KeyCount>
667<Contents><Key>PMC7906746.1/PMC7906746.1.json</Key><Size>1</Size></Contents>
668<Contents><Key>PMC7906746.1/PMC7906746.1.xml</Key><Size>1</Size></Contents>
669<Contents><Key>PMC7906746.1/gr1_lrg.jpg</Key><Size>1</Size></Contents>
670</ListBucketResult>"#;
671
672 let keys = PmcCloudClient::parse_cloud_listing(xml).unwrap();
673 assert_eq!(
674 keys,
675 vec![
676 "PMC7906746.1/PMC7906746.1.json".to_string(),
677 "PMC7906746.1/PMC7906746.1.xml".to_string(),
678 "PMC7906746.1/gr1_lrg.jpg".to_string(),
679 ]
680 );
681 }
682
683 #[cfg(not(target_arch = "wasm32"))]
684 #[test]
685 fn test_parse_cloud_listing_skips_folder_markers() {
686 let xml = r#"<ListBucketResult><Contents><Key>PMC1.1/</Key></Contents><Contents><Key>PMC1.1/PMC1.1.xml</Key></Contents></ListBucketResult>"#;
687 let keys = PmcCloudClient::parse_cloud_listing(xml).unwrap();
688 assert_eq!(keys, vec!["PMC1.1/PMC1.1.xml".to_string()]);
689 }
690
691 #[cfg(not(target_arch = "wasm32"))]
692 #[test]
693 fn test_select_latest_version_keys_picks_highest() {
694 let keys = vec![
695 "PMC1.1/PMC1.1.xml".to_string(),
696 "PMC1.1/gr1.jpg".to_string(),
697 "PMC1.2/PMC1.2.xml".to_string(),
698 "PMC1.2/gr1.jpg".to_string(),
699 ];
700 let latest = PmcCloudClient::select_latest_version_keys(keys);
701 assert_eq!(
702 latest,
703 vec![
704 "PMC1.2/PMC1.2.xml".to_string(),
705 "PMC1.2/gr1.jpg".to_string(),
706 ]
707 );
708 }
709
710 #[cfg(not(target_arch = "wasm32"))]
711 #[test]
712 fn test_find_downloaded_xml() {
713 let files = vec![
714 "/tmp/PMC9991720/gr1_lrg.jpg".to_string(),
715 "/tmp/PMC9991720/PMC9991720.1.xml".to_string(),
716 "/tmp/PMC9991720/PMC9991720.1.json".to_string(),
717 ];
718 assert_eq!(
719 PmcCloudClient::find_downloaded_xml(&files, "PMC9991720"),
720 Some("/tmp/PMC9991720/PMC9991720.1.xml".to_string())
721 );
722 assert_eq!(
724 PmcCloudClient::find_downloaded_xml(
725 &["/tmp/pmc9991720.1.XML".to_string()],
726 "PMC9991720"
727 ),
728 Some("/tmp/pmc9991720.1.XML".to_string())
729 );
730 assert_eq!(
732 PmcCloudClient::find_downloaded_xml(
733 &["/tmp/PMC9991720/gr1.jpg".to_string()],
734 "PMC9991720"
735 ),
736 None
737 );
738 assert_eq!(
740 PmcCloudClient::find_downloaded_xml(
741 &["/tmp/PMC0000001.1.xml".to_string()],
742 "PMC9991720"
743 ),
744 None
745 );
746 }
747
748 #[cfg(not(target_arch = "wasm32"))]
749 #[test]
750 fn test_select_latest_version_keys_empty() {
751 assert!(PmcCloudClient::select_latest_version_keys(vec![]).is_empty());
752 }
753
754 #[cfg(not(target_arch = "wasm32"))]
755 fn figure(id: &str, label: Option<&str>, graphic_href: Option<&str>) -> Figure {
756 Figure {
757 id: id.to_string(),
758 label: label.map(|s| s.to_string()),
759 caption: None,
760 alt_text: None,
761 fig_type: None,
762 graphic_href: graphic_href.map(|s| s.to_string()),
763 }
764 }
765
766 #[cfg(not(target_arch = "wasm32"))]
767 const IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "gif", "tif", "tiff"];
768
769 #[cfg(not(target_arch = "wasm32"))]
770 #[test]
771 fn test_find_matching_file_by_graphic_href() {
772 let files = vec![
773 "PMC1/PMC1.xml".to_string(),
774 "PMC1/gr1_lrg.jpg".to_string(),
775 "PMC1/fig2.png".to_string(),
776 ];
777 let fig = figure("fig-1", None, Some("gr1_lrg.jpg"));
779 assert_eq!(
780 PmcCloudClient::find_matching_file(&fig, &files, IMAGE_EXTS),
781 Some("PMC1/gr1_lrg.jpg".to_string())
782 );
783 }
784
785 #[cfg(not(target_arch = "wasm32"))]
786 #[test]
787 fn test_find_matching_file_by_figure_id() {
788 let files = vec!["PMC1/PMC1.xml".to_string(), "PMC1/GR1.PNG".to_string()];
789 let fig = figure("gr1", None, None);
791 assert_eq!(
792 PmcCloudClient::find_matching_file(&fig, &files, IMAGE_EXTS),
793 Some("PMC1/GR1.PNG".to_string())
794 );
795 }
796
797 #[cfg(not(target_arch = "wasm32"))]
798 #[test]
799 fn test_find_matching_file_by_label() {
800 let files = vec!["PMC1/PMC1.xml".to_string(), "PMC1/figure1.jpg".to_string()];
801 let fig = figure("unrelated-id", Some("Figure 1."), None);
803 assert_eq!(
804 PmcCloudClient::find_matching_file(&fig, &files, IMAGE_EXTS),
805 Some("PMC1/figure1.jpg".to_string())
806 );
807 }
808
809 #[cfg(not(target_arch = "wasm32"))]
810 #[test]
811 fn test_find_matching_file_id_requires_image_extension() {
812 let files = vec!["PMC1/gr1.xml".to_string()];
814 let fig = figure("gr1", None, None);
815 assert_eq!(
816 PmcCloudClient::find_matching_file(&fig, &files, IMAGE_EXTS),
817 None
818 );
819 }
820
821 #[cfg(not(target_arch = "wasm32"))]
822 #[test]
823 fn test_find_matching_file_no_match() {
824 let files = vec!["PMC1/other.jpg".to_string()];
825 let fig = figure("gr9", Some("Figure 9"), Some("missing.png"));
826 assert_eq!(
827 PmcCloudClient::find_matching_file(&fig, &files, IMAGE_EXTS),
828 None
829 );
830 }
831}