Skip to main content

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(&note_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}