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, // 0-5 (SM-2 grades) pub time_spent: i64, // milliseconds } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CardMetadata { pub id: String, pub created_at: String, pub updated_at: String, pub last_reviewed: Option, pub ease_factor: f64, // SM-2: starts at 2.5 pub interval: i64, // SM-2: days between reviews pub repetitions: i32, // SM-2: number of times reviewed pub tags: Vec, pub related_cards: Vec, pub question: String, pub answer: String, pub sketch_data: Option, pub review_history: Vec, pub variations: Vec, pub is_mastered: bool, pub focused_count: i32, // biometric tracking } #[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, ) -> NifResult { 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 { 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())))?; // Store old version in variations for time-travel card.variations.push(CardVariation { timestamp: card.updated_at.clone(), question: card.question.clone(), answer: card.answer.clone(), }); // Keep only last 50 variations if card.variations.len() > 50 { card.variations.remove(0); } // Update with new content (expecting JSON) if let Ok(update) = serde_json::from_str::(&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 { 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 { 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())))?; // SM-2 Algorithm Implementation 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()); // SM-2 calculation 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; } // Ease Factor adjustment 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 }; // Mark as mastered if reviewed 5+ times with grade 4+ 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 { 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 { let db = sled::open("flashcards.db") .map_err(|e| rustler::Error::Term(Box::new(e.to_string())))?; let cards: Vec = 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 { 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())))?; // Find variation closest to timestamp 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 ]);