use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256 as Sha256Hasher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
use crate::ankiconnect::{Deck, Field, Model, Note as AnkiNote, NoteId, Tag};
use crate::compile::Format;
use crate::error::{Error, Result};
use crate::metadata::CompletedNote;
use futures;
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Sha256(String);
impl Sha256 {
pub fn as_str(&self) -> &str {
&self.0
}
pub fn from_bytes(data: &[u8]) -> Self {
let hash = Sha256Hasher::digest(data);
Self(format!("{:x}", hash))
}
pub fn from_text(text: &str) -> Self {
Self::from_bytes(text.as_bytes())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Label(String);
impl Label {
pub fn new(label: String) -> Self {
Self(label)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl From<String> for Label {
fn from(s: String) -> Self {
Self(s)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CacheEntry {
pub label: Label,
pub id: NoteId,
pub tags: Vec<Tag>,
pub hash: HashMap<Field, Option<Sha256>>,
pub deck: Deck,
pub model: Model,
}
impl CacheEntry {
pub fn get_field_hash(&self, field: &Field) -> Option<&Sha256> {
self.hash.get(field).and_then(|opt| opt.as_ref())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cache {
#[serde(flatten)]
entries: HashMap<String, CacheEntry>,
#[serde(skip)]
cache_file: Option<PathBuf>,
}
impl Cache {
pub fn new() -> Self {
Self {
entries: HashMap::new(),
cache_file: None,
}
}
pub fn with_file<P: AsRef<Path>>(cache_file: P) -> Self {
Self {
entries: HashMap::new(),
cache_file: Some(cache_file.as_ref().to_path_buf()),
}
}
pub async fn load_from_file<P: AsRef<Path>>(cache_file: P) -> Result<Self> {
let path = cache_file.as_ref();
if !path.exists() {
return Ok(Self::with_file(path));
}
let content = fs::read_to_string(path).await.map_err(|e| {
Error::cache(format!(
"Failed to read cache file '{}': {}",
path.display(),
e
))
})?;
let mut cache: Cache = serde_json::from_str(&content).map_err(|e| {
Error::cache(format!(
"Failed to parse cache file '{}': {}",
path.display(),
e
))
})?;
cache.cache_file = Some(path.to_path_buf());
Ok(cache)
}
pub async fn save(&self) -> Result<()> {
let path = self
.cache_file
.as_ref()
.ok_or_else(|| Error::cache("No cache file path specified"))?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).await.map_err(|e| {
Error::cache(format!(
"Failed to create cache directory '{}': {}",
parent.display(),
e
))
})?;
}
let content = serde_json::to_string_pretty(self)
.map_err(|e| Error::cache(format!("Failed to serialize cache: {}", e)))?;
fs::write(path, content).await.map_err(|e| {
Error::cache(format!(
"Failed to write cache file '{}': {}",
path.display(),
e
))
})?;
Ok(())
}
pub fn get(&self, label: &str) -> Option<&CacheEntry> {
self.entries.get(label)
}
pub fn insert(&mut self, label: String, entry: CacheEntry) {
self.entries.insert(label, entry);
}
pub fn contains(&self, label: &str) -> bool {
self.entries.contains_key(label)
}
pub fn entries(&self) -> &HashMap<String, CacheEntry> {
&self.entries
}
pub async fn update_from_note(
&mut self,
note: &CompletedNote,
note_id: NoteId,
field_hashes: HashMap<Field, Option<Sha256>>,
) -> Result<()> {
let label = Label::new(note.label.clone());
let deck = Deck::new(note.deck.clone());
let model = Model::new(note.model.clone());
let tags = note.tags.iter().map(|t| Tag::new(t.clone())).collect();
let entry = CacheEntry {
label,
id: note_id,
tags,
hash: field_hashes,
deck,
model,
};
self.insert(note.label.clone(), entry);
Ok(())
}
pub fn get_note_id(&self, label: &str) -> Option<NoteId> {
self.get(label).map(|entry| entry.id.clone())
}
pub async fn create_field_hashes(
&self,
anki_note: &AnkiNote,
note_metadata: &CompletedNote,
) -> Result<HashMap<Field, Option<Sha256>>> {
let mut field_hashes: HashMap<Field, Option<Sha256>> = HashMap::new();
let mut file_futures = Vec::new();
for (field, value) in ¬e_metadata.data {
let field_key = Field::new(field.clone());
let media_path = if Format::parse(value.format.as_str())? == Format::Png {
anki_note.picture.as_ref().and_then(|pictures| {
pictures
.iter()
.find(|media| {
media
.fields
.as_ref()
.is_some_and(|fields| fields.iter().any(|f| f.as_str() == field))
})
.and_then(|media| media.path.clone())
})
} else {
None
};
match media_path {
Some(path) => {
let path = PathBuf::from(path);
file_futures.push(async move {
let content = fs::read(&path).await.map_err(|e| {
Error::cache(format!(
"Failed to read media file '{}': {}",
path.display(),
e
))
})?;
Ok::<(Field, Option<Sha256>), Error>((
field_key,
Some(Sha256::from_bytes(&content)),
))
});
}
None => {
let hash = anki_note
.fields
.get(&field_key)
.and_then(|v| v.0.as_ref())
.map(|s| Sha256::from_text(s.as_str()));
field_hashes.insert(field_key, hash);
}
}
}
for result in futures::future::join_all(file_futures).await {
let (field, hash) = result?;
field_hashes.insert(field, hash);
}
Ok(field_hashes)
}
}
impl Default for Cache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_is_deterministic() {
assert_eq!(Sha256::from_text("hello"), Sha256::from_text("hello"));
}
#[test]
fn sha256_differs_for_different_input() {
assert_ne!(Sha256::from_text("a"), Sha256::from_text("b"));
}
#[test]
fn sha256_from_text_matches_from_bytes() {
assert_eq!(Sha256::from_text("x"), Sha256::from_bytes(b"x"));
}
}