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