| use rustler::{NifResult, Binary}; |
| use serde::{Deserialize, Serialize}; |
| use chrono::{DateTime, Utc}; |
| use uuid::Uuid; |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct ReviewHistory { |
| pub review_date: String, |
| pub grade: i32, |
| pub time_spent: i64, |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct CardMetadata { |
| pub id: String, |
| pub created_at: String, |
| pub updated_at: String, |
| pub last_reviewed: Option<String>, |
| pub ease_factor: f64, |
| pub interval: i64, |
| pub repetitions: i32, |
| pub tags: Vec<String>, |
| pub related_cards: Vec<String>, |
| pub question: String, |
| pub answer: String, |
| pub sketch_data: Option<String>, |
| pub review_history: Vec<ReviewHistory>, |
| pub variations: Vec<CardVariation>, |
| pub is_mastered: bool, |
| pub focused_count: i32, |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize)] |
| pub struct CardVariation { |
| pub timestamp: String, |
| pub question: String, |
| pub answer: String, |
| } |
|
|
| #[derive_nifizer::nif] |
| #[rustler::nif] |
| fn create_card( |
| id: String, |
| question: String, |
| answer: String, |
| tags: Vec<String>, |
| ) -> NifResult<String> { |
| let card = CardMetadata { |
| id: id.clone(), |
| created_at: Utc::now().to_rfc3339(), |
| updated_at: Utc::now().to_rfc3339(), |
| last_reviewed: None, |
| ease_factor: 2.5, |
| interval: 1, |
| repetitions: 0, |
| tags, |
| related_cards: vec![], |
| question, |
| answer, |
| sketch_data: None, |
| review_history: vec![], |
| variations: vec![], |
| is_mastered: false, |
| focused_count: 0, |
| }; |
|
|
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let card_json = serde_json::to_string(&card) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| db.insert(id.as_bytes(), card_json.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let _ = db.flush(); |
| Ok(serde_json::to_string(&card).unwrap_or_default()) |
| } |
|
|
| #[rustler::nif] |
| fn save_card(id: String, content: String) -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| match db.get(id.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))? { |
| Some(existing) => { |
| let mut card: CardMetadata = serde_json::from_slice(&existing) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| |
| card.variations.push(CardVariation { |
| timestamp: card.updated_at.clone(), |
| question: card.question.clone(), |
| answer: card.answer.clone(), |
| }); |
|
|
| |
| if card.variations.len() > 50 { |
| card.variations.remove(0); |
| } |
|
|
| |
| if let Ok(update) = serde_json::from_str::<serde_json::Value>(&content) { |
| if let Some(q) = update.get("question").and_then(|v| v.as_str()) { |
| card.question = q.to_string(); |
| } |
| if let Some(a) = update.get("answer").and_then(|v| v.as_str()) { |
| card.answer = a.to_string(); |
| } |
| if let Some(tags) = update.get("tags").and_then(|v| v.as_array()) { |
| card.tags = tags.iter().filter_map(|t| t.as_str().map(|s| s.to_string())).collect(); |
| } |
| if let Some(sketch) = update.get("sketch_data").and_then(|v| v.as_str()) { |
| card.sketch_data = Some(sketch.to_string()); |
| } |
| } |
|
|
| card.updated_at = Utc::now().to_rfc3339(); |
| |
| let card_json = serde_json::to_string(&card) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| db.insert(id.as_bytes(), card_json.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let _ = db.flush(); |
| Ok("UPDATED".to_string()) |
| } |
| None => { |
| db.insert(id.as_bytes(), content.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| let _ = db.flush(); |
| Ok("CREATED".to_string()) |
| } |
| } |
| } |
|
|
| #[rustler::nif] |
| fn get_card(id: String) -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| match db.get(id.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))? { |
| Some(ivec) => Ok(String::from_utf8_lossy(&ivec).to_string()), |
| None => Ok("NOT_FOUND".to_string()), |
| } |
| } |
|
|
| #[rustler::nif] |
| fn record_review( |
| id: String, |
| grade: i32, |
| time_spent: i64, |
| ) -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| match db.get(id.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))? { |
| Some(existing) => { |
| let mut card: CardMetadata = serde_json::from_slice(&existing) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| |
| card.review_history.push(ReviewHistory { |
| review_date: Utc::now().to_rfc3339(), |
| grade, |
| time_spent, |
| }); |
|
|
| card.repetitions += 1; |
| card.last_reviewed = Some(Utc::now().to_rfc3339()); |
|
|
| |
| if grade >= 3 { |
| if card.repetitions == 1 { |
| card.interval = 1; |
| } else if card.repetitions == 2 { |
| card.interval = 3; |
| } else { |
| card.interval = (card.interval as f64 * card.ease_factor).ceil() as i64; |
| } |
| } else { |
| card.repetitions = 0; |
| card.interval = 1; |
| } |
|
|
| |
| let ef = card.ease_factor + (0.1 - (5.0 - grade as f64) * (0.08 + (5.0 - grade as f64) * 0.02)); |
| card.ease_factor = if ef < 1.3 { 1.3 } else { ef }; |
|
|
| |
| if card.repetitions >= 5 && grade >= 4 { |
| card.is_mastered = true; |
| } |
|
|
| let card_json = serde_json::to_string(&card) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| db.insert(id.as_bytes(), card_json.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let _ = db.flush(); |
| Ok(serde_json::to_string(&card).unwrap_or_default()) |
| } |
| None => Err(rustler::Error::Term(Box::new("CARD_NOT_FOUND"))), |
| } |
| } |
|
|
| #[rustler::nif] |
| fn add_relationship(card_id: String, related_id: String) -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| match db.get(card_id.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))? { |
| Some(existing) => { |
| let mut card: CardMetadata = serde_json::from_slice(&existing) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| if !card.related_cards.contains(&related_id) { |
| card.related_cards.push(related_id); |
| } |
|
|
| let card_json = serde_json::to_string(&card) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| db.insert(card_id.as_bytes(), card_json.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let _ = db.flush(); |
| Ok("RELATIONSHIP_ADDED".to_string()) |
| } |
| None => Err(rustler::Error::Term(Box::new("CARD_NOT_FOUND"))), |
| } |
| } |
|
|
| #[rustler::nif] |
| fn get_all_cards() -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| let cards: Vec<CardMetadata> = db.iter() |
| .filter_map(|item| { |
| item.ok().and_then(|(_, v)| { |
| serde_json::from_slice(&v).ok() |
| }) |
| }) |
| .collect(); |
|
|
| Ok(serde_json::to_string(&cards).unwrap_or_default()) |
| } |
|
|
| #[rustler::nif] |
| fn get_historical_snapshot(id: String, timestamp: String) -> NifResult<String> { |
| let db = sled::open("flashcards.db") |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| match db.get(id.as_bytes()) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))? { |
| Some(existing) => { |
| let card: CardMetadata = serde_json::from_slice(&existing) |
| .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; |
| |
| |
| let closest = card.variations.iter() |
| .min_by_key(|v| { |
| (v.timestamp.as_str().cmp(×tamp.as_str()), |
| std::cmp::Ordering::Equal) |
| }); |
|
|
| if let Some(variation) = closest { |
| Ok(serde_json::to_string(variation).unwrap_or_default()) |
| } else { |
| Ok(format!("{{\\"question\\": \\"{}\\", \\"answer\\": \\"{}\\"}}", |
| card.question.replace("\"", "\\\""), |
| card.answer.replace("\"", "\\\""))) |
| } |
| } |
| None => Err(rustler::Error::Term(Box::new("CARD_NOT_FOUND"))), |
| } |
| } |
|
|
| rustler::init!("flashsync_native", [ |
| create_card, |
| save_card, |
| get_card, |
| record_review, |
| add_relationship, |
| get_all_cards, |
| get_historical_snapshot |
| ]); |