use std::{ops::Range, path::PathBuf};
use anyhow::Context as _;
use colored::*;
use crate::{
cards_cache, config,
parse_file::{ANSWER_RE, DECK_RE, ID_RE, QUESTION_RE, is_card_empty},
utils,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CardModificationStatus {
Unknown,
New,
Updated,
Unchanged,
}
type CardCountPair = (usize, usize);
fn card_pair_status(
symbol: &str,
color: fn(s: &str) -> ColoredString,
(total, errors): &CardCountPair,
) -> String {
if *total == 0 {
return format!("{}{:0>2}", symbol.bright_black(), "0".bright_black());
}
let symbol = color(symbol);
if *errors == 0 {
format!("{}{:0>2}", symbol, color(total.to_string().as_str()))
} else {
format!(
"{}{:0>2}{}{:0>2}",
symbol,
(total - errors).to_string().red(),
"/".bright_black(),
color(total.to_string().as_str())
)
}
}
fn card_single_status(symbol: &str, color: fn(s: &str) -> ColoredString, count: usize) -> String {
if count == 0 {
return format!("{}{:0>2}", symbol.bright_black(), "0".bright_black());
}
let symbol = color(symbol);
format!("{}{:0>2}", symbol, color(count.to_string().as_str()))
}
#[derive(Debug, Clone)]
pub struct TypFileStats {
pub total_cards: usize,
pub new_cards: CardCountPair,
pub updated_cards: CardCountPair,
pub unchanged_cards: CardCountPair,
pub empty_cards: usize,
pub skipped_cards: usize,
}
pub type TFiles =
std::sync::Arc<std::sync::RwLock<std::collections::HashMap<PathBuf, TypFileStats>>>;
impl TypFileStats {
pub fn new(_filepath: PathBuf) -> Self {
Self {
total_cards: 0,
new_cards: (0, 0),
updated_cards: (0, 0),
unchanged_cards: (0, 0),
empty_cards: 0,
skipped_cards: 0,
}
}
pub fn total_errors(&self) -> usize {
self.new_cards.1 + self.updated_cards.1 + self.unchanged_cards.1
}
pub fn stats_colored(&self) -> String {
let separator = "|".bright_black();
format!(
"{}{}{}{}{}{}{}{}",
card_pair_status("+", |s| s.green(), &self.new_cards),
separator,
card_pair_status("↑", |s| s.green(), &self.updated_cards),
separator,
card_single_status("☓", |s| s.red(), self.total_errors()),
separator,
card_pair_status("↷", |s| s.white(), &self.unchanged_cards),
if self.empty_cards > 0 {
format!(
"{}{}",
separator,
card_single_status("∅", |s| s.blue(), self.empty_cards)
)
} else {
"".to_string()
}
)
}
}
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct BarebonesCardInfo {
pub card_id: String,
pub deck_name: String,
pub question: String,
pub answer: String,
pub byte_range: (usize, usize),
pub prelude_range: Option<Range<usize>>,
}
impl BarebonesCardInfo {
pub fn is_empty(&self) -> bool {
self.question.trim().is_empty() && self.answer.trim().is_empty()
}
}
#[derive(Debug, Clone)]
pub struct CardInfo {
pub source_file: PathBuf,
pub card_id: String,
pub deck_name: String,
pub anki_deck_name: Option<String>,
pub content: String,
pub content_hash: String,
pub modification_status: CardModificationStatus,
}
impl CardInfo {
pub fn from_string(
_internal_id: i64,
card_str: &str,
filepath: PathBuf,
) -> Result<Self, String> {
let card_id = ID_RE
.captures(card_str)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
if card_id.is_none() {
return Err("Card ID not found".to_string());
}
let card_id = card_id.unwrap();
let target_deck = DECK_RE
.captures(card_str)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()));
if target_deck.is_none() {
return Err("Target deck not found".to_string());
}
let target_deck = target_deck.unwrap();
Ok(Self {
source_file: filepath,
card_id,
deck_name: target_deck,
anki_deck_name: None,
content: card_str.to_string(),
content_hash: utils::hash_string(card_str),
modification_status: CardModificationStatus::Unknown,
})
}
pub fn set_status(&mut self, cards_cache_manager: &cards_cache::CardsCacheManager) {
let cfg = config::get();
let key = cards_cache::card_key(&self.deck_name, &self.card_id);
if let Some(old_hash) = cards_cache_manager.old_cache.get(&key) {
if old_hash.ends_with(&self.content_hash) {
if !old_hash.starts_with(&cards_cache_manager.static_hash)
&& cfg.recompile_on_config_change.read().unwrap().unwrap()
{
self.modification_status = CardModificationStatus::Updated;
} else {
self.modification_status = CardModificationStatus::Unchanged;
}
} else {
self.modification_status = CardModificationStatus::Updated;
}
} else {
self.modification_status = CardModificationStatus::New;
}
}
pub fn path_relative_to_root(&self) -> String {
let cfg = config::get();
cfg.path_relative_to_root(&self.source_file)
}
pub fn relative_ankiconf_path(&self) -> String {
let cfg = config::get();
let output_path = self.source_file.parent().unwrap_or(&cfg.path).to_path_buf();
let ankiconf_path = cfg.path.join("ankiconf.typ");
pathdiff::diff_paths(&ankiconf_path, &output_path)
.unwrap_or(ankiconf_path)
.to_string_lossy()
.into_owned()
}
pub fn image_path(&self, page: usize) -> String {
format!("typ-{}-{}.png", self.card_id, page)
}
pub fn is_empty(&self) -> bool {
is_card_empty(&self.content)
}
pub fn to_barebones(&self) -> anyhow::Result<BarebonesCardInfo> {
let question = QUESTION_RE
.captures(&self.content)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.context("Question not found in card content")?;
let answer = ANSWER_RE
.captures(&self.content)
.and_then(|caps| caps.get(1).map(|m| m.as_str().to_string()))
.context("Answer not found in card content")?;
Ok(BarebonesCardInfo {
card_id: self.card_id.clone(),
deck_name: self.deck_name.clone(),
question,
answer,
byte_range: (0, 0),
prelude_range: None,
})
}
}