1use std::collections::HashMap;
7
8use crate::Result;
9use ankit::AnkiClient;
10use serde::Serialize;
11
12#[derive(Debug, Clone, Default, Serialize)]
14pub struct StudySummary {
15 pub total_reviews: usize,
17 pub unique_cards: usize,
19 pub total_time_seconds: u64,
21 pub avg_reviews_per_day: f64,
23 pub daily: Vec<DailyStats>,
25}
26
27#[derive(Debug, Clone, Default, Serialize)]
29pub struct DailyStats {
30 pub date: String,
32 pub reviews: usize,
34 pub time_seconds: u64,
36}
37
38#[derive(Debug, Clone, Serialize)]
40pub struct ProblemCard {
41 pub card_id: i64,
43 pub note_id: i64,
45 pub lapses: i64,
47 pub reps: i64,
49 pub ease: i64,
51 pub interval: i64,
53 pub deck_name: String,
55 pub front: String,
57 pub reason: ProblemReason,
59}
60
61#[derive(Debug, Clone, Serialize)]
63pub enum ProblemReason {
64 HighLapseCount(i64),
66 LowEase(i64),
68 PoorRetention { reps: i64, interval: i64 },
70}
71
72#[derive(Debug, Clone)]
74pub struct ProblemCriteria {
75 pub min_lapses: i64,
77 pub max_ease: i64,
79 pub min_reps_for_retention: i64,
81 pub max_interval_for_retention: i64,
83}
84
85impl Default for ProblemCriteria {
86 fn default() -> Self {
87 Self {
88 min_lapses: 5,
89 max_ease: 2000, min_reps_for_retention: 10,
91 max_interval_for_retention: 7,
92 }
93 }
94}
95
96#[derive(Debug)]
98pub struct AnalyzeEngine<'a> {
99 client: &'a AnkiClient,
100}
101
102impl<'a> AnalyzeEngine<'a> {
103 pub(crate) fn new(client: &'a AnkiClient) -> Self {
104 Self { client }
105 }
106
107 pub async fn study_summary(&self, deck: &str, days: u32) -> Result<StudySummary> {
126 let daily_reviews = self.client.statistics().cards_reviewed_by_day().await?;
127
128 let mut summary = StudySummary::default();
129 let take_days = days as usize;
130
131 let recent: Vec<_> = daily_reviews.into_iter().take(take_days).collect();
133
134 for (date, count) in &recent {
135 summary.total_reviews += *count as usize;
136 summary.daily.push(DailyStats {
137 date: date.clone(),
138 reviews: *count as usize,
139 time_seconds: 0, });
141 }
142
143 if !recent.is_empty() {
144 summary.avg_reviews_per_day = summary.total_reviews as f64 / recent.len() as f64;
145 }
146
147 if deck != "*" {
149 let query = format!("deck:\"{}\" rated:{}", deck, days);
150 let cards = self.client.cards().find(&query).await?;
151 summary.unique_cards = cards.len();
152 }
153
154 Ok(summary)
155 }
156
157 pub async fn find_problems(
181 &self,
182 query: &str,
183 criteria: ProblemCriteria,
184 ) -> Result<Vec<ProblemCard>> {
185 let card_ids = self.client.cards().find(query).await?;
186
187 if card_ids.is_empty() {
188 return Ok(Vec::new());
189 }
190
191 let cards = self.client.cards().info(&card_ids).await?;
192 let mut problems = Vec::new();
193
194 for card in cards {
195 let reason = if card.lapses >= criteria.min_lapses {
196 Some(ProblemReason::HighLapseCount(card.lapses))
197 } else if card.ease_factor > 0 && card.ease_factor <= criteria.max_ease {
198 Some(ProblemReason::LowEase(card.ease_factor))
199 } else if card.reps >= criteria.min_reps_for_retention
200 && card.interval <= criteria.max_interval_for_retention
201 {
202 Some(ProblemReason::PoorRetention {
203 reps: card.reps,
204 interval: card.interval,
205 })
206 } else {
207 None
208 };
209
210 if let Some(reason) = reason {
211 let note_info = self.client.notes().info(&[card.note_id]).await?;
213 let front = note_info
214 .first()
215 .and_then(|n| n.fields.values().next())
216 .map(|f| f.value.clone())
217 .unwrap_or_default();
218
219 problems.push(ProblemCard {
220 card_id: card.card_id,
221 note_id: card.note_id,
222 lapses: card.lapses,
223 reps: card.reps,
224 ease: card.ease_factor,
225 interval: card.interval,
226 deck_name: card.deck_name.clone(),
227 front,
228 reason,
229 });
230 }
231 }
232
233 Ok(problems)
234 }
235
236 pub async fn retention_stats(&self, deck: &str) -> Result<RetentionStats> {
254 let query = format!("deck:\"{}\" is:review", deck);
255 let card_ids = self.client.cards().find(&query).await?;
256
257 if card_ids.is_empty() {
258 return Ok(RetentionStats::default());
259 }
260
261 let cards = self.client.cards().info(&card_ids).await?;
262 let ease_factors = self.client.cards().get_ease(&card_ids).await?;
263
264 let total_lapses: i64 = cards.iter().map(|c| c.lapses).sum();
265 let total_reps: i64 = cards.iter().map(|c| c.reps).sum();
266 let avg_ease: i64 = if !ease_factors.is_empty() {
267 ease_factors.iter().sum::<i64>() / ease_factors.len() as i64
268 } else {
269 0
270 };
271 let avg_interval: i64 = if !cards.is_empty() {
272 cards.iter().map(|c| c.interval).sum::<i64>() / cards.len() as i64
273 } else {
274 0
275 };
276
277 Ok(RetentionStats {
278 total_cards: cards.len(),
279 total_reviews: total_reps as usize,
280 total_lapses: total_lapses as usize,
281 avg_ease,
282 avg_interval,
283 retention_rate: if total_reps > 0 {
284 1.0 - (total_lapses as f64 / total_reps as f64)
285 } else {
286 0.0
287 },
288 })
289 }
290
291 pub async fn deck_audit(&self, deck: &str) -> Result<DeckAudit> {
323 let mut audit = DeckAudit {
324 deck: deck.to_string(),
325 ..Default::default()
326 };
327
328 let query = format!("deck:\"{}\"", deck);
329
330 let card_ids = self.client.cards().find(&query).await?;
332 audit.total_cards = card_ids.len();
333
334 if card_ids.is_empty() {
335 return Ok(audit);
336 }
337
338 let cards = self.client.cards().info(&card_ids).await?;
340
341 let mut ease_sum: i64 = 0;
343 let mut ease_count: usize = 0;
344
345 for card in &cards {
346 *audit
348 .cards_by_model
349 .entry(card.model_name.clone())
350 .or_insert(0) += 1;
351
352 match card.card_type {
354 0 => audit.new_cards += 1,
355 1 | 3 => audit.learning_cards += 1,
356 2 => audit.review_cards += 1,
357 _ => {}
358 }
359
360 if card.queue == -1 {
362 audit.suspended_count += 1;
363 }
364
365 if card.lapses >= 8 {
367 audit.leech_count += 1;
368 }
369
370 if card.ease_factor > 0 {
372 ease_sum += card.ease_factor;
373 ease_count += 1;
374 }
375 }
376
377 if ease_count > 0 {
379 audit.average_ease = ease_sum as f64 / ease_count as f64;
380 }
381
382 let note_ids = self.client.notes().find(&query).await?;
384 audit.total_notes = note_ids.len();
385
386 if !note_ids.is_empty() {
387 let notes = self.client.notes().info(¬e_ids).await?;
388
389 for note in ¬es {
391 if note.tags.is_empty() {
392 audit.untagged_notes += 1;
393 } else {
394 for tag in ¬e.tags {
395 *audit.tag_distribution.entry(tag.clone()).or_insert(0) += 1;
396 }
397 }
398 }
399
400 let mut field_names: HashMap<String, bool> = HashMap::new();
402 for note in ¬es {
403 for (field_name, field_value) in ¬e.fields {
404 field_names.insert(field_name.clone(), true);
405 if field_value.value.trim().is_empty() {
406 *audit
407 .empty_field_counts
408 .entry(field_name.clone())
409 .or_insert(0) += 1;
410 }
411 }
412 }
413
414 let mut seen_values: HashMap<String, usize> = HashMap::new();
416 for note in ¬es {
417 if let Some(first_field) = note
419 .fields
420 .values()
421 .min_by_key(|f| f.order)
422 .map(|f| f.value.trim().to_lowercase())
423 {
424 if !first_field.is_empty() {
425 *seen_values.entry(first_field).or_insert(0) += 1;
426 }
427 }
428 }
429
430 audit.duplicate_count = seen_values.values().filter(|&&count| count > 1).count();
432 }
433
434 Ok(audit)
435 }
436
437 pub async fn study_report(&self, deck: &str, days: u32) -> Result<StudyReport> {
465 let mut report = StudyReport {
466 deck: deck.to_string(),
467 period_days: days,
468 ..Default::default()
469 };
470
471 let daily_reviews = self.client.statistics().cards_reviewed_by_day().await?;
473 let take_days = days as usize;
474 let recent: Vec<_> = daily_reviews.into_iter().take(take_days).collect();
475
476 for (date, count) in &recent {
478 report.total_reviews += *count as usize;
479 report.daily_stats.push(ReportDailyStats {
480 date: date.clone(),
481 reviews: *count as usize,
482 });
483 }
484
485 if !recent.is_empty() {
486 report.average_reviews_per_day = report.total_reviews as f64 / recent.len() as f64;
487 }
488
489 report.study_streak = recent.iter().take_while(|(_, count)| *count > 0).count() as u32;
491
492 let review_query = if deck == "*" {
494 "is:review".to_string()
495 } else {
496 format!("deck:\"{}\" is:review", deck)
497 };
498
499 let review_card_ids = self.client.cards().find(&review_query).await?;
500
501 if !review_card_ids.is_empty() {
502 let cards = self.client.cards().info(&review_card_ids).await?;
503
504 let total_lapses: i64 = cards.iter().map(|c| c.lapses).sum();
506 let total_reps: i64 = cards.iter().map(|c| c.reps).sum();
507
508 if total_reps > 0 {
509 report.retention_rate = 1.0 - (total_lapses as f64 / total_reps as f64);
510 }
511
512 let ease_values: Vec<i64> = cards
513 .iter()
514 .filter(|c| c.ease_factor > 0)
515 .map(|c| c.ease_factor)
516 .collect();
517
518 if !ease_values.is_empty() {
519 report.average_ease =
520 ease_values.iter().sum::<i64>() as f64 / ease_values.len() as f64;
521 }
522
523 for card in &cards {
525 if card.lapses >= 8 {
527 report.leeches.push(card.card_id);
528 }
529 if card.ease_factor > 0 && card.ease_factor < 2000 {
531 report.low_ease_cards.push(card.card_id);
532 }
533 }
534
535 report.relearning_cards = cards.iter().filter(|c| c.card_type == 3).count();
537 }
538
539 if deck != "*" {
541 let rated_query = format!("deck:\"{}\" rated:{}", deck, days);
542 let rated_cards = self.client.cards().find(&rated_query).await?;
543
544 if !rated_cards.is_empty() {
545 let card_infos = self.client.cards().info(&rated_cards).await?;
546
547 for card in &card_infos {
549 match card.card_type {
550 0 => report.new_cards_studied += 1,
551 2 => report.review_cards_studied += 1,
552 _ => {}
553 }
554 }
555 }
556 }
557
558 let due_tomorrow_query = if deck == "*" {
560 "prop:due=1".to_string()
561 } else {
562 format!("deck:\"{}\" prop:due=1", deck)
563 };
564 let due_tomorrow_cards = self.client.cards().find(&due_tomorrow_query).await?;
565 report.due_tomorrow = due_tomorrow_cards.len();
566
567 let due_week_query = if deck == "*" {
568 "prop:due<=7".to_string()
569 } else {
570 format!("deck:\"{}\" prop:due<=7", deck)
571 };
572 let due_week_cards = self.client.cards().find(&due_week_query).await?;
573 report.due_this_week = due_week_cards.len();
574
575 Ok(report)
576 }
577
578 pub async fn compare_decks(
621 &self,
622 deck_a: &str,
623 deck_b: &str,
624 options: CompareOptions,
625 ) -> Result<DeckComparison> {
626 let mut comparison = DeckComparison {
627 deck_a: deck_a.to_string(),
628 deck_b: deck_b.to_string(),
629 key_field: options.key_field.clone(),
630 similarity_threshold: options.similarity_threshold,
631 ..Default::default()
632 };
633
634 let query_a = format!("deck:\"{}\"", deck_a);
636 let query_b = format!("deck:\"{}\"", deck_b);
637
638 let note_ids_a = self.client.notes().find(&query_a).await?;
639 let note_ids_b = self.client.notes().find(&query_b).await?;
640
641 if note_ids_a.is_empty() && note_ids_b.is_empty() {
642 return Ok(comparison);
643 }
644
645 let notes_a = if note_ids_a.is_empty() {
647 Vec::new()
648 } else {
649 self.client.notes().info(¬e_ids_a).await?
650 };
651
652 let notes_b = if note_ids_b.is_empty() {
653 Vec::new()
654 } else {
655 self.client.notes().info(¬e_ids_b).await?
656 };
657
658 let extract_key = |note: &ankit::NoteInfo| -> Option<(i64, String, Vec<String>)> {
660 note.fields
661 .get(&options.key_field)
662 .map(|f| (note.note_id, f.value.trim().to_string(), note.tags.clone()))
663 };
664
665 let keys_a: Vec<_> = notes_a.iter().filter_map(extract_key).collect();
666 let keys_b: Vec<_> = notes_b.iter().filter_map(extract_key).collect();
667
668 let map_b: HashMap<String, (i64, Vec<String>)> = keys_b
670 .iter()
671 .map(|(id, key, tags)| (key.to_lowercase(), (*id, tags.clone())))
672 .collect();
673
674 let mut matched_in_a: std::collections::HashSet<i64> = std::collections::HashSet::new();
676 let mut matched_in_b: std::collections::HashSet<i64> = std::collections::HashSet::new();
677
678 for (note_id_a, key_a, tags_a) in &keys_a {
680 let key_lower = key_a.to_lowercase();
681 if let Some((note_id_b, tags_b)) = map_b.get(&key_lower) {
682 matched_in_a.insert(*note_id_a);
683 matched_in_b.insert(*note_id_b);
684
685 comparison.exact_matches.push((
686 ComparisonNote {
687 note_id: *note_id_a,
688 key_value: key_a.clone(),
689 tags: tags_a.clone(),
690 },
691 ComparisonNote {
692 note_id: *note_id_b,
693 key_value: key_a.clone(), tags: tags_b.clone(),
695 },
696 ));
697 }
698 }
699
700 if options.similarity_threshold < 1.0 {
702 for (note_id_a, key_a, tags_a) in &keys_a {
703 if matched_in_a.contains(note_id_a) {
704 continue;
705 }
706
707 for (note_id_b, key_b, tags_b) in &keys_b {
708 if matched_in_b.contains(note_id_b) {
709 continue;
710 }
711
712 let similarity = string_similarity(key_a, key_b);
713 if similarity >= options.similarity_threshold {
714 matched_in_a.insert(*note_id_a);
715 matched_in_b.insert(*note_id_b);
716
717 comparison.similar.push(SimilarPair {
718 note_a: ComparisonNote {
719 note_id: *note_id_a,
720 key_value: key_a.clone(),
721 tags: tags_a.clone(),
722 },
723 note_b: ComparisonNote {
724 note_id: *note_id_b,
725 key_value: key_b.clone(),
726 tags: tags_b.clone(),
727 },
728 similarity,
729 });
730
731 break; }
733 }
734 }
735 }
736
737 for (note_id_a, key_a, tags_a) in &keys_a {
739 if !matched_in_a.contains(note_id_a) {
740 comparison.only_in_a.push(ComparisonNote {
741 note_id: *note_id_a,
742 key_value: key_a.clone(),
743 tags: tags_a.clone(),
744 });
745 }
746 }
747
748 for (note_id_b, key_b, tags_b) in &keys_b {
749 if !matched_in_b.contains(note_id_b) {
750 comparison.only_in_b.push(ComparisonNote {
751 note_id: *note_id_b,
752 key_value: key_b.clone(),
753 tags: tags_b.clone(),
754 });
755 }
756 }
757
758 Ok(comparison)
759 }
760
761 pub async fn study_plan(&self, deck: &str, options: PlanOptions) -> Result<StudyPlan> {
799 let mut plan = StudyPlan {
800 deck: deck.to_string(),
801 ..Default::default()
802 };
803
804 let due_query = format!("deck:\"{}\" is:due -is:suspended", deck);
806 let due_card_ids = self.client.cards().find(&due_query).await?;
807 plan.total_due = due_card_ids.len();
808
809 let new_query = format!("deck:\"{}\" is:new -is:suspended", deck);
811 let new_card_ids = self.client.cards().find(&new_query).await?;
812 plan.total_new_available = new_card_ids.len();
813
814 if due_card_ids.is_empty() && new_card_ids.is_empty() {
815 plan.recommendations
816 .push("No cards to study! Consider adding new material.".to_string());
817 return Ok(plan);
818 }
819
820 let due_cards = if due_card_ids.is_empty() {
822 Vec::new()
823 } else {
824 self.client.cards().info(&due_card_ids).await?
825 };
826
827 let total_seconds = options.target_time_minutes * 60;
829
830 let mut leech_ids: Vec<i64> = Vec::new();
832 let mut regular_review_ids: Vec<i64> = Vec::new();
833
834 for card in &due_cards {
835 if card.lapses >= options.leech_threshold {
836 leech_ids.push(card.card_id);
837 } else {
838 regular_review_ids.push(card.card_id);
839 }
840 }
841
842 plan.leech_count = leech_ids.len();
843
844 let mut remaining_seconds = total_seconds;
846 let mut selected_reviews: Vec<i64> = Vec::new();
847 let mut selected_new: Vec<i64> = Vec::new();
848
849 if options.prioritize_leeches && !leech_ids.is_empty() {
851 let leech_time = leech_ids.len() as u32 * options.seconds_per_review_card;
852 if leech_time <= remaining_seconds {
853 selected_reviews.extend(&leech_ids);
854 remaining_seconds -= leech_time;
855 } else {
856 let max_leeches = (remaining_seconds / options.seconds_per_review_card) as usize;
858 selected_reviews.extend(leech_ids.iter().take(max_leeches));
859 remaining_seconds = 0;
860 }
861 }
862
863 if remaining_seconds > 0 {
865 let new_time_budget = (remaining_seconds as f64 * options.new_card_ratio) as u32;
867 let review_time_budget = remaining_seconds - new_time_budget;
868
869 let max_new = (new_time_budget / options.seconds_per_new_card) as usize;
871 let max_reviews = (review_time_budget / options.seconds_per_review_card) as usize;
872
873 let available_reviews: Vec<i64> = if options.prioritize_leeches {
875 regular_review_ids.clone()
876 } else {
877 due_card_ids.clone()
878 };
879
880 let reviews_to_add = available_reviews.iter().take(max_reviews);
881 selected_reviews.extend(reviews_to_add);
882
883 let new_to_add = new_card_ids.iter().take(max_new);
885 selected_new.extend(new_to_add);
886 }
887
888 let mut ordered_cards: Vec<(i64, CardPriority)> = Vec::new();
891
892 if options.prioritize_leeches {
893 for &id in &leech_ids {
894 if selected_reviews.contains(&id) {
895 ordered_cards.push((id, CardPriority::Leech));
896 }
897 }
898 }
899
900 for &id in &selected_reviews {
901 if !leech_ids.contains(&id) || !options.prioritize_leeches {
902 ordered_cards.push((id, CardPriority::DueReview));
903 }
904 }
905
906 for &id in &selected_new {
907 ordered_cards.push((id, CardPriority::New));
908 }
909
910 ordered_cards.sort_by_key(|(_, priority)| *priority);
912
913 plan.suggested_order = ordered_cards.into_iter().map(|(id, _)| id).collect();
914 plan.review_count = selected_reviews.len();
915 plan.new_count = selected_new.len();
916
917 let review_time = plan.review_count as u32 * options.seconds_per_review_card;
919 let new_time = plan.new_count as u32 * options.seconds_per_new_card;
920 plan.estimated_time = (review_time + new_time) / 60;
921
922 if plan.leech_count > 0 {
924 plan.recommendations.push(format!(
925 "You have {} leech cards that need extra attention.",
926 plan.leech_count
927 ));
928 }
929
930 if plan.total_due > plan.review_count {
931 plan.recommendations.push(format!(
932 "Only {} of {} due cards fit in your target time.",
933 plan.review_count, plan.total_due
934 ));
935 }
936
937 if plan.total_new_available > 0 && plan.new_count == 0 {
938 plan.recommendations
939 .push("No time for new cards today. Consider increasing study time.".to_string());
940 } else if plan.new_count > 0 {
941 plan.recommendations.push(format!(
942 "Introducing {} new cards ({:.0}% of session).",
943 plan.new_count,
944 (plan.new_count as f64 / (plan.review_count + plan.new_count) as f64) * 100.0
945 ));
946 }
947
948 if plan.review_count + plan.new_count == 0 {
949 plan.recommendations
950 .push("No cards fit the target time. Try increasing study time.".to_string());
951 }
952
953 let actual_ratio = if plan.review_count + plan.new_count > 0 {
954 plan.new_count as f64 / (plan.review_count + plan.new_count) as f64
955 } else {
956 0.0
957 };
958
959 if actual_ratio < options.new_card_ratio * 0.5 && plan.total_new_available > 0 {
960 plan.recommendations.push(
961 "New card ratio is below target. You may be accumulating a review backlog."
962 .to_string(),
963 );
964 }
965
966 Ok(plan)
967 }
968}
969
970fn string_similarity(a: &str, b: &str) -> f64 {
974 let a_lower = a.to_lowercase();
975 let b_lower = b.to_lowercase();
976
977 if a_lower == b_lower {
978 return 1.0;
979 }
980
981 if a_lower.is_empty() || b_lower.is_empty() {
982 return 0.0;
983 }
984
985 let distance = levenshtein_distance(&a_lower, &b_lower);
986 let max_len = a_lower.chars().count().max(b_lower.chars().count());
987
988 1.0 - (distance as f64 / max_len as f64)
989}
990
991fn levenshtein_distance(a: &str, b: &str) -> usize {
993 let a_chars: Vec<char> = a.chars().collect();
994 let b_chars: Vec<char> = b.chars().collect();
995
996 let m = a_chars.len();
997 let n = b_chars.len();
998
999 if m == 0 {
1000 return n;
1001 }
1002 if n == 0 {
1003 return m;
1004 }
1005
1006 let mut prev: Vec<usize> = (0..=n).collect();
1008 let mut curr = vec![0; n + 1];
1009
1010 for i in 1..=m {
1011 curr[0] = i;
1012
1013 for j in 1..=n {
1014 let cost = if a_chars[i - 1] == b_chars[j - 1] {
1015 0
1016 } else {
1017 1
1018 };
1019
1020 curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); }
1024
1025 std::mem::swap(&mut prev, &mut curr);
1026 }
1027
1028 prev[n]
1029}
1030
1031#[derive(Debug, Clone, Default, Serialize)]
1036pub struct StudyReport {
1037 pub deck: String,
1039 pub period_days: u32,
1041
1042 pub total_reviews: usize,
1045 pub total_time_minutes: u64,
1047 pub average_reviews_per_day: f64,
1049 pub study_streak: u32,
1051
1052 pub retention_rate: f64,
1055 pub average_ease: f64,
1057
1058 pub new_cards_studied: usize,
1061 pub review_cards_studied: usize,
1063 pub relearning_cards: usize,
1065
1066 pub leeches: Vec<i64>,
1069 pub low_ease_cards: Vec<i64>,
1071
1072 pub due_tomorrow: usize,
1075 pub due_this_week: usize,
1077
1078 pub daily_stats: Vec<ReportDailyStats>,
1081}
1082
1083#[derive(Debug, Clone, Default, Serialize)]
1085pub struct ReportDailyStats {
1086 pub date: String,
1088 pub reviews: usize,
1090}
1091
1092#[derive(Debug, Clone)]
1094pub struct CompareOptions {
1095 pub key_field: String,
1097 pub similarity_threshold: f64,
1101}
1102
1103impl Default for CompareOptions {
1104 fn default() -> Self {
1105 Self {
1106 key_field: "Front".to_string(),
1107 similarity_threshold: 0.9,
1108 }
1109 }
1110}
1111
1112#[derive(Debug, Clone, Default, Serialize)]
1114pub struct DeckComparison {
1115 pub deck_a: String,
1117 pub deck_b: String,
1119 pub key_field: String,
1121 pub similarity_threshold: f64,
1123
1124 pub only_in_a: Vec<ComparisonNote>,
1126 pub only_in_b: Vec<ComparisonNote>,
1128 pub exact_matches: Vec<(ComparisonNote, ComparisonNote)>,
1130 pub similar: Vec<SimilarPair>,
1132}
1133
1134#[derive(Debug, Clone, Serialize)]
1136pub struct ComparisonNote {
1137 pub note_id: i64,
1139 pub key_value: String,
1141 pub tags: Vec<String>,
1143}
1144
1145#[derive(Debug, Clone, Serialize)]
1147pub struct SimilarPair {
1148 pub note_a: ComparisonNote,
1150 pub note_b: ComparisonNote,
1152 pub similarity: f64,
1154}
1155
1156#[derive(Debug, Clone, Default, Serialize)]
1158pub struct RetentionStats {
1159 pub total_cards: usize,
1161 pub total_reviews: usize,
1163 pub total_lapses: usize,
1165 pub avg_ease: i64,
1167 pub avg_interval: i64,
1169 pub retention_rate: f64,
1171}
1172
1173#[derive(Debug, Clone, Default, Serialize)]
1178pub struct DeckAudit {
1179 pub deck: String,
1181 pub total_cards: usize,
1183 pub total_notes: usize,
1185
1186 pub cards_by_model: HashMap<String, usize>,
1189
1190 pub tag_distribution: HashMap<String, usize>,
1193 pub untagged_notes: usize,
1195
1196 pub empty_field_counts: HashMap<String, usize>,
1199
1200 pub duplicate_count: usize,
1203
1204 pub leech_count: usize,
1207 pub suspended_count: usize,
1209
1210 pub new_cards: usize,
1213 pub learning_cards: usize,
1215 pub review_cards: usize,
1217 pub average_ease: f64,
1219}
1220
1221#[derive(Debug, Clone)]
1223pub struct PlanOptions {
1224 pub target_time_minutes: u32,
1226 pub new_card_ratio: f64,
1228 pub prioritize_leeches: bool,
1230 pub seconds_per_new_card: u32,
1232 pub seconds_per_review_card: u32,
1234 pub leech_threshold: i64,
1236}
1237
1238impl Default for PlanOptions {
1239 fn default() -> Self {
1240 Self {
1241 target_time_minutes: 30,
1242 new_card_ratio: 0.2,
1243 prioritize_leeches: true,
1244 seconds_per_new_card: 30, seconds_per_review_card: 8, leech_threshold: 8,
1247 }
1248 }
1249}
1250
1251#[derive(Debug, Clone, Default, Serialize)]
1253pub struct StudyPlan {
1254 pub deck: String,
1256 pub estimated_time: u32,
1258 pub review_count: usize,
1260 pub new_count: usize,
1262 pub leech_count: usize,
1264 pub total_due: usize,
1266 pub total_new_available: usize,
1268 pub recommendations: Vec<String>,
1270 pub suggested_order: Vec<i64>,
1272}
1273
1274#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
1276enum CardPriority {
1277 Leech,
1279 DueReview,
1281 New,
1283}