use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use pulldown_cmark::Event;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use crate::error::ErrorReport;
use crate::error::Fallible;
use crate::media::resolve::MediaResolver;
use crate::media::resolve::MediaResolverBuilder;
use crate::types::card::Card;
use crate::types::card::CardContent;
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct MissingMedia {
pub file_path: String,
pub card_file: PathBuf,
pub card_lines: (usize, usize),
}
fn extract_media_paths(markdown: &str) -> Vec<String> {
let parser = Parser::new(markdown);
let mut paths = Vec::new();
for event in parser {
if let Event::Start(Tag::Image { dest_url, .. }) = event {
paths.push(dest_url.to_string());
}
}
paths
}
pub fn validate_media_files(cards: &[Card], base_dir: &Path) -> Fallible<()> {
let base_dir = base_dir.to_path_buf();
let mut missing = HashSet::new();
for card in cards {
let resolver: MediaResolver = MediaResolverBuilder::new()
.with_collection_path(base_dir.clone())?
.with_deck_path(card.relative_file_path(&base_dir)?)?
.build()?;
let markdown_texts = match card.content() {
CardContent::Basic { question, answer } => vec![question.as_str(), answer.as_str()],
CardContent::Cloze { text, .. } => vec![text.as_str()],
};
for markdown in markdown_texts {
for path in extract_media_paths(markdown) {
match resolver.resolve(&path) {
Ok(_) => {}
Err(_) => {
missing.insert(MissingMedia {
file_path: path,
card_file: card.file_path().clone(),
card_lines: card.range(),
});
}
}
}
}
}
if !missing.is_empty() {
let mut missing: Vec<MissingMedia> = missing.into_iter().collect();
missing.sort();
let mut msg = String::from("Missing media files referenced in cards:\n");
for m in missing {
msg.push_str(&format!(
" - {} (referenced in {}:{})\n",
m.file_path,
m.card_file.display(),
m.card_lines.0
));
}
return Err(ErrorReport::new(&msg));
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::env::temp_dir;
use std::fs::create_dir_all;
use super::*;
use crate::parser::Parser as CardParser;
#[test]
fn test_extract_media_paths() {
let markdown = "Here is an image: \nAnd another: ";
let paths = extract_media_paths(markdown);
assert_eq!(paths, vec!["@/foo.jpg", "@/bar.png"]);
}
#[test]
fn test_extract_media_paths_with_audio() {
let markdown = "Audio file: ";
let paths = extract_media_paths(markdown);
assert_eq!(paths, vec!["@/sound.mp3"]);
}
#[test]
fn test_extract_media_paths_no_media() {
let markdown = "Just some **bold** text.";
let paths = extract_media_paths(markdown);
assert!(paths.is_empty());
}
#[test]
fn test_extract_media_paths_with_urls() {
let markdown = " and ";
let paths = extract_media_paths(markdown);
assert_eq!(paths, vec!["https://example.com/image.jpg", "local.png"]);
}
#[test]
fn test_validate_media_files_with_missing_files() -> Fallible<()> {
let test_dir = temp_dir().join("hashcards_media_test");
create_dir_all(&test_dir)?;
let card_file = test_dir.join("test_deck.md");
std::fs::write(&card_file, b"fake deck data")?;
let markdown = "Q: What is this image?\n\n\n\nA: Unknown\n\nQ: What is this audio?\nA: ";
let parser = CardParser::new("test_deck".to_string(), card_file.clone());
let cards = parser.parse(markdown)?;
let result = validate_media_files(&cards, &test_dir);
assert!(result.is_err());
let err = result.err().unwrap();
let err_msg = err.to_string();
assert!(err_msg.contains("Missing media files referenced in cards:"));
assert!(err_msg.contains("missing_image.jpg"));
assert!(err_msg.contains("missing_audio.mp3"));
assert!(err_msg.contains("test_deck.md"));
Ok(())
}
#[test]
fn test_validate_media_files_with_existing_files() -> Fallible<()> {
let test_dir = temp_dir().join("hashcards_media_test_existing");
create_dir_all(&test_dir)?;
let image_path = test_dir.join("existing_image.jpg");
std::fs::write(&image_path, b"fake image data")?;
let card_file = test_dir.join("test_deck.md");
std::fs::write(&card_file, b"fake deck data")?;
let markdown = "Q: What is this image?\n\n\n\nA: A test image";
let parser = CardParser::new("test_deck".to_string(), card_file.clone());
let cards = parser.parse(markdown)?;
let result = validate_media_files(&cards, &test_dir);
assert_eq!(result, Ok(()));
Ok(())
}
#[test]
fn test_validate_media_files_with_cloze_cards() -> Fallible<()> {
let test_dir = temp_dir().join("hashcards_media_test_cloze");
create_dir_all(&test_dir)?;
let card_file = test_dir.join("test_deck.md");
std::fs::write(&card_file, "")?;
let markdown = "C: The capital of [France] is ";
let parser = CardParser::new("test_deck".to_string(), card_file.clone());
let cards: Vec<Card> = parser.parse(markdown)?;
let result = validate_media_files(&cards, &test_dir);
assert!(result.is_err());
let err_msg = result.err().unwrap().to_string();
assert!(err_msg.contains("paris.jpg"));
Ok(())
}
}