diff --git a/src/lib.rs b/src/lib.rs index 19bbac4..4ef2719 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -171,6 +171,13 @@ //! //! You should only put the filename (aka basename) and not the full path in the field; `` will *not* work. Media files should have unique filenames. //! +//! ### Media files from memory +//! If you want to add `media_files` from memory use [`Package::new_from_memory`] instead of [`Package::new`]: +//! ```rust +//! // ... +//! let mut my_package = Package::new_from_memory(vec![deck], vec![MediaFile::new_from_bytes(&mp3_bytes, "sound.mp3"), MediaFile::new_from_bytes(&jpg_bytes, "image.jpg")])?; +//! ``` +//! //! ### sort_field //! Anki has a value for each `Note` called the `sort_field`. Anki uses this //! value to sort the cards in the Browse interface. Anki also is happier if @@ -203,6 +210,7 @@ pub use error::Error; pub use model::{Model, ModelType}; pub use note::Note; pub use package::Package; +pub use package::MediaFile; #[cfg(test)] mod tests { @@ -582,7 +590,7 @@ def check_media(col): #[test] #[serial] - fn media_files() { + fn media_files_fs() { let tmp_dir = TempDir::new().unwrap(); std::env::set_current_dir(tmp_dir.path()).unwrap(); @@ -620,6 +628,32 @@ def check_media(col): assert!(missing.contains(&"missing.mp3".to_string())); }); } + #[test] + #[serial] + fn media_files_mem() { + let mut deck = Deck::new(123456, "foodeck", ""); + let note = Note::new( + model(), + vec![ + "question [sound:present.mp3] [sound:missing.mp3]", + r#"answer "#, + ], + ) + .unwrap(); + deck.add_note(note); + Python::with_gil(|py| { + let mut setup = TestSetup::new(&py); + setup.import_package( + Package::new_from_memory(vec![deck], vec![MediaFile::new_from_bytes(VALID_MP3, "present.mp3"), MediaFile::new_from_bytes(VALID_JPG, "present.jpg")]).unwrap(), + None, + ); + + let (missing, _, _) = setup.check_media(); + assert_eq!(missing.len(), 2); + assert!(missing.contains(&"missing.jpg".to_string())); + assert!(missing.contains(&"missing.mp3".to_string())); + }); + } #[test] #[serial] diff --git a/src/package.rs b/src/package.rs index 96a18cd..3c8ece7 100644 --- a/src/package.rs +++ b/src/package.rs @@ -17,7 +17,7 @@ use std::str::FromStr; /// `Package` to pack `Deck`s and `media_files` and write them to a `.apkg` file /// -/// Example: +/// # Example (media files on the filesystem): /// ```rust /// use genanki_rs::{Package, Deck, Note, Model, Field, Template}; /// @@ -41,11 +41,70 @@ use std::str::FromStr; /// let mut package = Package::new(vec![my_deck], vec!["sound.mp3", "images/image.jpg"])?; /// package.write_to_file("output.apkg")?; /// ``` +/// # Example (media files from memory): +/// ```rust +/// use genanki_rs::{Package, Deck, Note, Model, Field, Template, MediaFile}; + +/// const VALID_MP3: &[u8] = +/// b"\xff\xe3\x18\xc4\x00\x00\x00\x03H\x00\x00\x00\x00LAME3.98.2\x00\x00\x00\ +/// \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +/// \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\ +/// \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"; +/// +/// const VALID_JPG: &[u8] = +/// b"\xff\xd8\xff\xdb\x00C\x00\x03\x02\x02\x02\x02\x02\x03\x02\x02\x02\x03\x03\ +/// \x03\x03\x04\x06\x04\x04\x04\x04\x04\x08\x06\x06\x05\x06\t\x08\n\n\t\x08\t\ +/// \t\n\x0c\x0f\x0c\n\x0b\x0e\x0b\t\t\r\x11\r\x0e\x0f\x10\x10\x11\x10\n\x0c\ +/// \x12\x13\x12\x10\x13\x0f\x10\x10\x10\xff\xc9\x00\x0b\x08\x00\x01\x00\x01\ +/// \x01\x01\x11\x00\xff\xcc\x00\x06\x00\x10\x10\x05\xff\xda\x00\x08\x01\x01\ +/// \x00\x00?\x00\xd2\xcf \xff\xd9"; +/// +/// let model = Model::new( +/// 1607392319, +/// "Simple Model", +/// vec![ +/// Field::new("Question"), +/// Field::new("Answer"), +/// Field::new("MyMedia"), +/// ], +/// vec![Template::new("Card 1") +/// .qfmt("{{Question}}{{Question}}
{{MyMedia}}") +/// .afmt(r#"{{FrontSide}}
{{Answer}}"#)], +/// ); +/// let mut deck = Deck::new(1234, "Example Deck", "Example Deck with media"); +/// deck.add_note(Note::new(model.clone(), vec!["What is the capital of France?", "Paris", "[sound:sound.mp3]"])?); +/// deck.add_note(Note::new(model.clone(), vec!["What is the capital of France?", "Paris", r#""#])?); +/// +/// let mut package = Package::new_from_memory(vec![deck], vec![MediaFile::new_from_bytes(VALID_MP3, "sound.mp3"), MediaFile::new_from_bytes(VALID_JPG, "image.jpg")])?; +/// package.write_to_file("output.apkg")?; +/// ``` pub struct Package { decks: Vec, - media_files: Vec, + media_files: Vec, } +/// the location of the media files, either as a path on the filesystem or as bytes from memory +pub enum MediaFile { + /// a path on the filesystem + Path(PathBuf), + /// bytes of the file and a filename + Bytes(Vec, String), +} +impl MediaFile { + /// Create a new `MediaFile` from a path on the filesystem + pub fn new_from_file>(path: P) -> Self { + Self::Path(path.as_ref().to_path_buf()) + } + /// Create a new `MediaFile` from a path on the filesystem using a `&str` + pub fn new_from_file_path(path: &str) -> Result { + Ok(Self::Path(PathBuf::from_str(path)?)) + } + + /// Create a new `MediaFile` from bytes from memory and a filename + pub fn new_from_bytes(bytes: &[u8], name: &str) -> Self { + Self::Bytes(bytes.to_vec(), name.to_owned()) + } +} impl Package { /// Create a new package with `decks` and `media_files` /// @@ -53,11 +112,19 @@ impl Package { pub fn new(decks: Vec, media_files: Vec<&str>) -> Result { let media_files = media_files .iter() - .map(|&s| PathBuf::from_str(s)) + .map(|&s| PathBuf::from_str(s).map(|p| MediaFile::Path(p))) .collect::, _>>()?; Ok(Self { decks, media_files }) } + /// Create a new package with `decks` and `media_files`, + /// where `media_files` can be bytes from memory or a path on the filesystem + /// + /// Returns `Err` if `media_files` are invalid + pub fn new_from_memory(decks: Vec, media_files: Vec) -> Result { + Ok(Self { decks, media_files }) + } + /// Writes the package to any writer that implements Write and Seek pub fn write(&mut self, writer: W) -> Result<(), Error> { self.write_maybe_timestamp(writer, None) @@ -118,17 +185,20 @@ impl Package { .media_files .iter() .enumerate() - .collect::>(); + .collect::>(); let media_map = media_file_idx_to_path .clone() .into_iter() - .map(|(id, path)| { + .map(|(id, media_file)| { ( id.to_string(), - path.file_name() - .expect("Should always have a filename") - .to_str() - .expect("should always have string"), + match media_file { + MediaFile::Path(path) => path.file_name() + .expect("Should always have a filename") + .to_str() + .expect("should always have string"), + MediaFile::Bytes(_, name) => name, + }, ) }) .collect::>(); @@ -138,11 +208,14 @@ impl Package { .map_err(zip_error)?; outzip.write_all(media_json.as_bytes())?; - for (idx, &path) in &media_file_idx_to_path { + for (idx, &media_file) in &media_file_idx_to_path { outzip .start_file(idx.to_string(), FileOptions::default()) .map_err(zip_error)?; - outzip.write_all(&read_file_bytes(path)?)?; + outzip.write_all(&match media_file { + MediaFile::Path(path) => read_file_bytes(path)?, + MediaFile::Bytes(bytes, _) => bytes.clone(), + })?; } outzip.finish().map_err(zip_error)?; Ok(())