use crate::dezoomer::{
Dezoomer, DezoomerError, DezoomerInput, DezoomerInputWithContents, ImageUrl, Images,
};
use custom_error::custom_error;
custom_error! {pub BulkTextError
InvalidUrlOrPath{line_number: usize, input: String} = "On line {line_number}: '{input}' is not a valid URL or file path"
}
impl From<BulkTextError> for DezoomerError {
fn from(err: BulkTextError) -> Self {
DezoomerError::Other { source: err.into() }
}
}
#[derive(Default)]
pub struct BulkTextDezoomer;
impl Dezoomer for BulkTextDezoomer {
fn name(&self) -> &'static str {
"bulk_text"
}
fn images(&mut self, data: &DezoomerInput) -> Result<Images, DezoomerError> {
let extension = data.uri.rsplit('.').next();
let is_bulk_file = (extension.is_some_and(|ext| {
ext.eq_ignore_ascii_case("txt") || ext.eq_ignore_ascii_case("urls")
}) || data.uri.contains("bulk")
|| data.uri.contains("list"))
&& !data.uri.contains("{{")
&& !data.uri.contains("}}");
self.assert(is_bulk_file)?;
let DezoomerInputWithContents { uri: _, contents } = data.with_contents()?;
let content = std::str::from_utf8(contents).map_err(|e| DezoomerError::DownloadError {
msg: format!("Failed to parse text file as UTF-8: {e}"),
})?;
let urls = parse_text_urls(content)?;
if urls.is_empty() {
return Err(DezoomerError::Other {
source: Box::new(std::io::Error::other("No valid URLs found in text file")),
});
}
Ok(urls.into())
}
}
fn validate_url_or_path(input: &str, line_number: usize) -> Result<(), BulkTextError> {
if url::Url::parse(input).is_ok() {
return Ok(());
}
if std::path::Path::new(input).exists() {
return Ok(());
}
if input.contains("{{X}}") || input.contains("{{Y}}") {
return Ok(());
}
Err(BulkTextError::InvalidUrlOrPath {
line_number,
input: input.to_string(),
})
}
fn parse_text_urls(content: &str) -> Result<Vec<ImageUrl>, BulkTextError> {
let mut urls = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let mut parts = trimmed.splitn(2, char::is_whitespace);
let url_part = parts.next().unwrap_or_default();
let custom_title = parts.next().filter(|s| !s.is_empty());
validate_url_or_path(url_part, line_num + 1)?;
let title = Some(custom_title.map_or_else(
|| extract_title_from_url(url_part, line_num + 1),
str::to_string,
));
urls.push(ImageUrl {
url: url_part.to_string(),
title,
});
}
Ok(urls)
}
fn extract_title_from_url(url: &str, line_number: usize) -> String {
if let Ok(parsed_url) = url::Url::parse(url)
&& let Some(segments) = parsed_url.path_segments()
{
let segments: Vec<&str> = segments.collect();
if let Some(last_segment) = segments.iter().rev().find(|s| !s.is_empty()) {
let title = if let Some(dot_pos) = last_segment.rfind('.') {
&last_segment[..dot_pos]
} else {
last_segment
};
if !title.is_empty() {
return title.to_string();
}
}
}
format!("URL_{line_number}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dezoomer::{
PageContents,
test_utils::{assert_error_contains, expect_image_urls},
};
#[test]
fn test_parse_empty_content() {
let urls = parse_text_urls("");
assert!(urls.unwrap().is_empty());
}
#[test]
fn test_parse_comments_and_empty_lines() {
let content = "# This is a comment\n\n \n# Another comment";
let urls = parse_text_urls(content);
assert!(urls.unwrap().is_empty());
}
#[test]
fn test_parse_valid_urls() {
let content = "http://example.com/image1.jpg\nhttps://example.org/manifest.json";
let urls = parse_text_urls(content).unwrap();
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].url, "http://example.com/image1.jpg");
assert_eq!(urls[0].title, Some("image1".to_string()));
assert_eq!(urls[1].url, "https://example.org/manifest.json");
assert_eq!(urls[1].title, Some("manifest".to_string()));
}
#[test]
fn test_parse_mixed_content() {
let content = "# IIIF manifests\nhttp://example.com/manifest1.json\n\n# Images\nhttps://example.org/info.json\n# End";
let urls = parse_text_urls(content).unwrap();
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].url, "http://example.com/manifest1.json");
assert_eq!(urls[0].title, Some("manifest1".to_string()));
assert_eq!(urls[1].url, "https://example.org/info.json");
assert_eq!(urls[1].title, Some("info".to_string()));
}
#[test]
fn test_parse_urls_with_custom_titles() {
let content = "http://example.com/image1.jpg My Custom Title\nhttps://example.org/manifest.json Another Title";
let urls = parse_text_urls(content).unwrap();
assert_eq!(urls.len(), 2);
assert_eq!(urls[0].url, "http://example.com/image1.jpg");
assert_eq!(urls[0].title, Some("My Custom Title".to_string()));
assert_eq!(urls[1].url, "https://example.org/manifest.json");
assert_eq!(urls[1].title, Some("Another Title".to_string()));
}
#[test]
fn test_parse_invalid_url() {
let content = "not_a_valid_url";
assert_error_contains(parse_text_urls(content), &["line 1", "not_a_valid_url"]);
}
#[test]
fn test_extract_title_from_url() {
assert_eq!(
extract_title_from_url("http://example.com/image.jpg", 1),
"image"
);
assert_eq!(
extract_title_from_url("https://example.org/path/manifest.json", 2),
"manifest"
);
assert_eq!(extract_title_from_url("http://example.com/", 3), "URL_3");
assert_eq!(extract_title_from_url("not_a_url", 4), "URL_4");
}
#[test]
fn test_images() {
let mut dezoomer = BulkTextDezoomer;
let content = "http://example.com/image1.jpg\nhttps://example.org/manifest.json".as_bytes();
let input = DezoomerInput {
uri: "file://test.txt".to_string(),
contents: PageContents::Success(content.to_vec()),
};
let urls = expect_image_urls(dezoomer.images(&input).unwrap());
assert_eq!(
urls.iter().map(|url| url.url.as_str()).collect::<Vec<_>>(),
[
"http://example.com/image1.jpg",
"https://example.org/manifest.json"
]
);
}
#[test]
fn test_images_empty_file() {
let mut dezoomer = BulkTextDezoomer;
let content = "# Only comments\n\n# Nothing else".as_bytes();
let input = DezoomerInput {
uri: "file://empty.txt".to_string(),
contents: PageContents::Success(content.to_vec()),
};
assert_error_contains(dezoomer.images(&input), &["No valid URLs found"]);
}
#[test]
fn test_images_invalid_url() {
let mut dezoomer = BulkTextDezoomer;
let content = "not_a_valid_url".as_bytes();
let input = DezoomerInput {
uri: "file://invalid.txt".to_string(),
contents: PageContents::Success(content.to_vec()),
};
assert_error_contains(dezoomer.images(&input), &["line 1", "not_a_valid_url"]);
}
}