use std::path::Path;
use std::path::PathBuf;
use maud::Markup;
use maud::PreEscaped;
use maud::html;
use crate::error::Fallible;
use crate::markdown::MarkdownRenderConfig;
use crate::markdown::markdown_to_html;
use crate::markdown::markdown_to_html_inline;
use crate::types::aliases::DeckName;
use crate::types::card_hash::CardHash;
use crate::types::card_hash::Hasher;
const CLOZE_TAG_BYTES: &[u8] = b"CLOZE_DELETION";
const CLOZE_TAG: &str = "CLOZE_DELETION";
#[derive(Clone)]
pub struct Card {
deck_name: DeckName,
file_path: PathBuf,
range: (usize, usize),
content: CardContent,
hash: CardHash,
}
#[derive(Clone)]
pub enum CardContent {
Basic {
question: String,
answer: String,
},
Cloze {
text: String,
start: usize,
end: usize,
},
}
#[derive(Debug, PartialEq, Eq)]
pub enum CardType {
Basic,
Cloze,
}
impl Card {
pub fn new(
deck_name: DeckName,
file_path: PathBuf,
range: (usize, usize),
content: CardContent,
) -> Self {
let hash = content.hash();
Self {
deck_name,
file_path,
content,
range,
hash,
}
}
pub fn deck_name(&self) -> &DeckName {
&self.deck_name
}
pub fn content(&self) -> &CardContent {
&self.content
}
pub fn hash(&self) -> CardHash {
self.hash
}
pub fn family_hash(&self) -> Option<CardHash> {
self.content.family_hash()
}
pub fn file_path(&self) -> &PathBuf {
&self.file_path
}
pub fn relative_file_path(&self, collection_root: &Path) -> Fallible<PathBuf> {
let canon_root: PathBuf = collection_root.canonicalize()?;
let canon_file: PathBuf = self.file_path.canonicalize()?;
let result: PathBuf = canon_file.strip_prefix(&canon_root)?.to_path_buf();
Ok(result)
}
pub fn range(&self) -> (usize, usize) {
self.range
}
pub fn card_type(&self) -> CardType {
match &self.content {
CardContent::Basic { .. } => CardType::Basic,
CardContent::Cloze { .. } => CardType::Cloze,
}
}
pub fn html_front(&self, config: &MarkdownRenderConfig) -> Fallible<Markup> {
self.content.html_front(config)
}
pub fn html_back(&self, config: &MarkdownRenderConfig) -> Fallible<Markup> {
self.content.html_back(config)
}
}
impl CardContent {
pub fn new_basic(question: impl Into<String>, answer: impl Into<String>) -> Self {
Self::Basic {
question: question.into().trim().to_string(),
answer: answer.into().trim().to_string(),
}
}
pub fn new_cloze(prompt: impl Into<String>, start: usize, end: usize) -> Self {
Self::Cloze {
text: prompt.into(),
start,
end,
}
}
pub fn hash(&self) -> CardHash {
let mut hasher = Hasher::new();
match &self {
CardContent::Basic { question, answer } => {
hasher.update(b"Basic");
hasher.update(question.as_bytes());
hasher.update(answer.as_bytes());
}
CardContent::Cloze { text, start, end } => {
hasher.update(b"Cloze");
hasher.update(text.as_bytes());
hasher.update(&start.to_le_bytes());
hasher.update(&end.to_le_bytes());
}
}
hasher.finalize()
}
pub fn family_hash(&self) -> Option<CardHash> {
match &self {
CardContent::Basic { .. } => None,
CardContent::Cloze { text, .. } => {
let mut hasher = Hasher::new();
hasher.update(b"Cloze");
hasher.update(text.as_bytes());
Some(hasher.finalize())
}
}
}
pub fn html_front(&self, config: &MarkdownRenderConfig) -> Fallible<Markup> {
let html = match self {
CardContent::Basic { question, .. } => {
html! {
(PreEscaped(markdown_to_html(config, question)?))
}
}
CardContent::Cloze { text, start, end } => {
let mut text_bytes: Vec<u8> = text.as_bytes().to_owned();
text_bytes.splice(*start..*end + 1, CLOZE_TAG_BYTES.iter().copied());
let text: String = String::from_utf8(text_bytes)?;
let text: String = markdown_to_html(config, &text)?;
let text: String =
text.replace(CLOZE_TAG, "<span class='cloze'>.............</span>");
html! {
(PreEscaped(text))
}
}
};
Ok(html)
}
pub fn html_back(&self, config: &MarkdownRenderConfig) -> Fallible<Markup> {
let html = match self {
CardContent::Basic { answer, .. } => {
html! {
(PreEscaped(markdown_to_html(config, answer)?))
}
}
CardContent::Cloze { text, start, end } => {
let mut text_bytes: Vec<u8> = text.as_bytes().to_owned();
let deleted_text: Vec<u8> = text_bytes[*start..*end + 1].to_owned();
let deleted_text: String = String::from_utf8(deleted_text)?;
let deleted_text: String = markdown_to_html_inline(config, &deleted_text)?;
text_bytes.splice(*start..*end + 1, CLOZE_TAG_BYTES.iter().copied());
let text: String = String::from_utf8(text_bytes)?;
let text = markdown_to_html(config, &text)?;
let text = text.replace(
CLOZE_TAG,
&format!("<span class='cloze-reveal'>{}</span>", deleted_text),
);
html! {
(PreEscaped(text))
}
}
};
Ok(html)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_card_hash() {
let card1 = CardContent::new_basic("What is 2+2?", "4");
let card2 = CardContent::new_basic("What is 2+2?", "4");
let card3 = CardContent::new_basic("What is 3+3?", "6");
assert_eq!(card1.hash(), card2.hash());
assert_ne!(card1.hash(), card3.hash());
}
#[test]
fn test_cloze_card_hash() {
let a = CardContent::new_cloze("The capital of France is Paris", 0, 1);
let b = CardContent::new_cloze("The capital of France is Paris", 0, 2);
assert_eq!(a.family_hash(), b.family_hash());
}
#[test]
fn test_family_hash() {
let a = CardContent::new_cloze("The capital of France is Paris", 0, 1);
let b = CardContent::new_cloze("The capital of France is Paris", 0, 2);
assert_eq!(a.family_hash(), b.family_hash());
}
}