ankit_engine/organize.rs
1//! Deck organization operations.
2//!
3//! This module provides high-level workflows for deck cloning,
4//! merging, and tag-based reorganization.
5
6use crate::{Error, NoteBuilder, Result};
7use ankit::AnkiClient;
8
9/// Report of a deck clone operation.
10#[derive(Debug, Clone, Default)]
11pub struct CloneReport {
12 /// Number of notes cloned.
13 pub notes_cloned: usize,
14 /// Number of notes that failed to clone.
15 pub notes_failed: usize,
16 /// Name of the destination deck.
17 pub destination: String,
18}
19
20/// Report of a deck merge operation.
21#[derive(Debug, Clone, Default)]
22pub struct MergeReport {
23 /// Number of cards moved.
24 pub cards_moved: usize,
25 /// Source decks that were merged.
26 pub sources: Vec<String>,
27 /// Destination deck.
28 pub destination: String,
29}
30
31/// Organization workflow engine.
32#[derive(Debug)]
33pub struct OrganizeEngine<'a> {
34 client: &'a AnkiClient,
35}
36
37impl<'a> OrganizeEngine<'a> {
38 pub(crate) fn new(client: &'a AnkiClient) -> Self {
39 Self { client }
40 }
41
42 /// Clone a deck with all its notes.
43 ///
44 /// Creates a new deck with copies of all notes from the source deck.
45 /// Scheduling information is not preserved (cards start as new).
46 ///
47 /// # Arguments
48 ///
49 /// * `source` - Name of the deck to clone
50 /// * `destination` - Name for the new deck
51 ///
52 /// # Example
53 ///
54 /// ```no_run
55 /// # use ankit_engine::Engine;
56 /// # async fn example() -> ankit_engine::Result<()> {
57 /// let engine = Engine::new();
58 /// let report = engine.organize().clone_deck("Japanese", "Japanese Copy").await?;
59 /// println!("Cloned {} notes", report.notes_cloned);
60 /// # Ok(())
61 /// # }
62 /// ```
63 pub async fn clone_deck(&self, source: &str, destination: &str) -> Result<CloneReport> {
64 // Verify source exists
65 let decks = self.client.decks().names().await?;
66 if !decks.contains(&source.to_string()) {
67 return Err(Error::DeckNotFound(source.to_string()));
68 }
69
70 // Create destination deck
71 self.client.decks().create(destination).await?;
72
73 // Get all notes from source
74 let query = format!("deck:\"{}\"", source);
75 let note_ids = self.client.notes().find(&query).await?;
76 let note_infos = self.client.notes().info(¬e_ids).await?;
77
78 let mut report = CloneReport {
79 destination: destination.to_string(),
80 ..Default::default()
81 };
82
83 // Clone each note
84 for info in note_infos {
85 let mut builder = NoteBuilder::new(destination, &info.model_name);
86
87 for (field_name, field_info) in info.fields {
88 builder = builder.field(field_name, field_info.value);
89 }
90
91 builder = builder.tags(info.tags);
92
93 // Allow duplicates in the new deck
94 let note = builder.allow_duplicate(true).build();
95
96 match self.client.notes().add(note).await {
97 Ok(_) => report.notes_cloned += 1,
98 Err(_) => report.notes_failed += 1,
99 }
100 }
101
102 Ok(report)
103 }
104
105 /// Merge multiple decks into one.
106 ///
107 /// Moves all cards from source decks into the destination deck.
108 /// Does not delete the source decks.
109 ///
110 /// # Arguments
111 ///
112 /// * `sources` - Names of decks to merge
113 /// * `destination` - Name of the destination deck
114 ///
115 /// # Example
116 ///
117 /// ```no_run
118 /// # use ankit_engine::Engine;
119 /// # async fn example() -> ankit_engine::Result<()> {
120 /// let engine = Engine::new();
121 /// let report = engine.organize()
122 /// .merge_decks(&["Deck A", "Deck B"], "Combined")
123 /// .await?;
124 /// # Ok(())
125 /// # }
126 /// ```
127 pub async fn merge_decks(&self, sources: &[&str], destination: &str) -> Result<MergeReport> {
128 // Create destination if it doesn't exist
129 self.client.decks().create(destination).await?;
130
131 let mut report = MergeReport {
132 destination: destination.to_string(),
133 sources: sources.iter().map(|s| s.to_string()).collect(),
134 ..Default::default()
135 };
136
137 // Move cards from each source
138 for source in sources {
139 let query = format!("deck:\"{}\"", source);
140 let card_ids = self.client.cards().find(&query).await?;
141
142 if !card_ids.is_empty() {
143 self.client
144 .decks()
145 .move_cards(&card_ids, destination)
146 .await?;
147 report.cards_moved += card_ids.len();
148 }
149 }
150
151 Ok(report)
152 }
153
154 /// Move notes matching a tag to a different deck.
155 ///
156 /// # Arguments
157 ///
158 /// * `tag` - Tag to search for
159 /// * `destination` - Deck to move matching notes to
160 ///
161 /// # Example
162 ///
163 /// ```no_run
164 /// # use ankit_engine::Engine;
165 /// # async fn example() -> ankit_engine::Result<()> {
166 /// let engine = Engine::new();
167 /// let moved = engine.organize()
168 /// .move_by_tag("verb", "Japanese::Grammar::Verbs")
169 /// .await?;
170 /// println!("Moved {} cards", moved);
171 /// # Ok(())
172 /// # }
173 /// ```
174 pub async fn move_by_tag(&self, tag: &str, destination: &str) -> Result<usize> {
175 // Create destination if needed
176 self.client.decks().create(destination).await?;
177
178 // Find cards with tag
179 let query = format!("tag:{}", tag);
180 let card_ids = self.client.cards().find(&query).await?;
181
182 if !card_ids.is_empty() {
183 self.client
184 .decks()
185 .move_cards(&card_ids, destination)
186 .await?;
187 }
188
189 Ok(card_ids.len())
190 }
191
192 /// Reorganize cards by tag into subdecks.
193 ///
194 /// For each unique tag, creates a subdeck under the parent deck
195 /// and moves matching cards there.
196 ///
197 /// # Arguments
198 ///
199 /// * `source_deck` - Deck to reorganize
200 /// * `parent_deck` - Parent deck for new subdecks
201 /// * `tags` - Tags to use for organization
202 ///
203 /// # Example
204 ///
205 /// ```no_run
206 /// # use ankit_engine::Engine;
207 /// # async fn example() -> ankit_engine::Result<()> {
208 /// let engine = Engine::new();
209 /// let report = engine.organize()
210 /// .reorganize_by_tags("Japanese", "Japanese", &["verb", "noun", "adjective"])
211 /// .await?;
212 /// # Ok(())
213 /// # }
214 /// ```
215 pub async fn reorganize_by_tags(
216 &self,
217 source_deck: &str,
218 parent_deck: &str,
219 tags: &[&str],
220 ) -> Result<ReorganizeReport> {
221 let mut report = ReorganizeReport::default();
222
223 for tag in tags {
224 let subdeck = format!("{}::{}", parent_deck, tag);
225
226 // Find cards in source deck with this tag
227 let query = format!("deck:\"{}\" tag:{}", source_deck, tag);
228 let card_ids = self.client.cards().find(&query).await?;
229
230 if !card_ids.is_empty() {
231 self.client.decks().create(&subdeck).await?;
232 self.client.decks().move_cards(&card_ids, &subdeck).await?;
233 report
234 .moved
235 .push((tag.to_string(), subdeck, card_ids.len()));
236 }
237 }
238
239 Ok(report)
240 }
241}
242
243/// Report of a reorganization operation.
244#[derive(Debug, Clone, Default)]
245pub struct ReorganizeReport {
246 /// List of (tag, destination deck, card count) for each reorganization.
247 pub moved: Vec<(String, String, usize)>,
248}