Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@
//!
//! You should only put the filename (aka basename) and not the full path in the field; `<img src="images/image.jpg">` 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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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 <img src="present.jpg"> <img src="missing.jpg">"#,
],
)
.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]
Expand Down
95 changes: 84 additions & 11 deletions src/package.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
///
Expand All @@ -41,23 +41,90 @@ 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}}<br>{{MyMedia}}")
/// .afmt(r#"{{FrontSide}}<hr id="answer">{{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#"<img src="image.jpg">"#])?);
///
/// 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<Deck>,
media_files: Vec<PathBuf>,
media_files: Vec<MediaFile>,
}
/// 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<u8>, String),
}
impl MediaFile {
/// Create a new `MediaFile` from a path on the filesystem
pub fn new_from_file<P: AsRef<Path>>(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<Self, Error> {
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())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible not to call to_vec here and just keep a reference to the bytes? Could potentially avoid a chunky memcopy

}
}
impl Package {
/// Create a new package with `decks` and `media_files`
///
/// Returns `Err` if `media_files` are invalid
pub fn new(decks: Vec<Deck>, media_files: Vec<&str>) -> Result<Self, Error> {
let media_files = media_files
.iter()
.map(|&s| PathBuf::from_str(s))
.map(|&s| PathBuf::from_str(s).map(|p| MediaFile::Path(p)))
.collect::<Result<Vec<_>, _>>()?;
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<Deck>, media_files: Vec<MediaFile>) -> Result<Self, Error> {
Ok(Self { decks, media_files })
}

/// Writes the package to any writer that implements Write and Seek
pub fn write<W: Write + Seek>(&mut self, writer: W) -> Result<(), Error> {
self.write_maybe_timestamp(writer, None)
Expand Down Expand Up @@ -118,17 +185,20 @@ impl Package {
.media_files
.iter()
.enumerate()
.collect::<HashMap<usize, &PathBuf>>();
.collect::<HashMap<usize, &MediaFile>>();
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::<HashMap<String, &str>>();
Expand All @@ -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(),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clone is probably not required?

})?;
}
outzip.finish().map_err(zip_error)?;
Ok(())
Expand Down