1use crate::{Error, NoteBuilder, Result};
7use ankit::AnkiClient;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone)]
12pub struct MigrationConfig {
13 pub source_model: String,
15 pub target_model: String,
17 pub field_mapping: HashMap<String, String>,
19 pub target_deck: Option<String>,
21 pub delete_source: bool,
23 pub add_tags: Vec<String>,
25}
26
27#[derive(Debug, Clone, Default)]
29pub struct MigrationReport {
30 pub migrated: usize,
32 pub failed: usize,
34 pub deleted: usize,
36 pub errors: Vec<MigrationError>,
38}
39
40#[derive(Debug, Clone)]
42pub struct MigrationError {
43 pub note_id: i64,
45 pub error: String,
47}
48
49#[derive(Debug)]
51pub struct MigrateEngine<'a> {
52 client: &'a AnkiClient,
53}
54
55impl<'a> MigrateEngine<'a> {
56 pub(crate) fn new(client: &'a AnkiClient) -> Self {
57 Self { client }
58 }
59
60 pub async fn notes(
94 &self,
95 config: MigrationConfig,
96 query: Option<&str>,
97 ) -> Result<MigrationReport> {
98 let models = self.client.models().names().await?;
100 if !models.contains(&config.source_model) {
101 return Err(Error::ModelNotFound(config.source_model));
102 }
103 if !models.contains(&config.target_model) {
104 return Err(Error::ModelNotFound(config.target_model));
105 }
106
107 let target_fields = self
109 .client
110 .models()
111 .field_names(&config.target_model)
112 .await?;
113 for target_field in config.field_mapping.values() {
114 if !target_fields.contains(target_field) {
115 return Err(Error::MissingField {
116 model: config.target_model.clone(),
117 field: target_field.clone(),
118 });
119 }
120 }
121
122 let base_query = format!("note:\"{}\"", config.source_model);
124 let full_query = match query {
125 Some(q) => format!("{} {}", base_query, q),
126 None => base_query,
127 };
128
129 let note_ids = self.client.notes().find(&full_query).await?;
130 let note_infos = self.client.notes().info(¬e_ids).await?;
131
132 let mut report = MigrationReport::default();
133 let mut notes_to_delete = Vec::new();
134
135 for info in note_infos {
136 let mut new_fields = HashMap::new();
138 for (source_field, target_field) in &config.field_mapping {
139 if let Some(field_info) = info.fields.get(source_field) {
140 new_fields.insert(target_field.clone(), field_info.value.clone());
141 }
142 }
143
144 let deck = if let Some(ref deck) = config.target_deck {
147 deck.clone()
148 } else {
149 if !info.cards.is_empty() {
151 let card_info = self.client.cards().info(&info.cards[..1]).await?;
152 card_info
153 .first()
154 .map(|c| c.deck_name.clone())
155 .unwrap_or_else(|| "Default".to_string())
156 } else {
157 "Default".to_string()
158 }
159 };
160
161 let mut builder = NoteBuilder::new(&deck, &config.target_model);
163 for (field, value) in &new_fields {
164 builder = builder.field(field, value);
165 }
166
167 builder = builder.tags(info.tags.iter().cloned());
169 builder = builder.tags(config.add_tags.iter().cloned());
170
171 let note = builder.allow_duplicate(true).build();
173
174 match self.client.notes().add(note).await {
175 Ok(_) => {
176 report.migrated += 1;
177 if config.delete_source {
178 notes_to_delete.push(info.note_id);
179 }
180 }
181 Err(e) => {
182 report.failed += 1;
183 report.errors.push(MigrationError {
184 note_id: info.note_id,
185 error: e.to_string(),
186 });
187 }
188 }
189 }
190
191 if !notes_to_delete.is_empty() {
193 self.client.notes().delete(¬es_to_delete).await?;
194 report.deleted = notes_to_delete.len();
195 }
196
197 Ok(report)
198 }
199
200 pub async fn preview(
204 &self,
205 config: &MigrationConfig,
206 query: Option<&str>,
207 ) -> Result<MigrationPreview> {
208 let models = self.client.models().names().await?;
210 let source_exists = models.contains(&config.source_model);
211 let target_exists = models.contains(&config.target_model);
212
213 let source_fields = if source_exists {
215 self.client
216 .models()
217 .field_names(&config.source_model)
218 .await?
219 } else {
220 Vec::new()
221 };
222
223 let target_fields = if target_exists {
224 self.client
225 .models()
226 .field_names(&config.target_model)
227 .await?
228 } else {
229 Vec::new()
230 };
231
232 let mut mapping_issues = Vec::new();
234 for (source, target) in &config.field_mapping {
235 if !source_fields.contains(source) {
236 mapping_issues.push(format!("Source field '{}' not found", source));
237 }
238 if !target_fields.contains(target) {
239 mapping_issues.push(format!("Target field '{}' not found", target));
240 }
241 }
242
243 let note_count = if source_exists {
245 let base_query = format!("note:\"{}\"", config.source_model);
246 let full_query = match query {
247 Some(q) => format!("{} {}", base_query, q),
248 None => base_query,
249 };
250 let notes = self.client.notes().find(&full_query).await?;
251 notes.len()
252 } else {
253 0
254 };
255
256 Ok(MigrationPreview {
257 source_model_exists: source_exists,
258 target_model_exists: target_exists,
259 source_fields,
260 target_fields,
261 notes_to_migrate: note_count,
262 mapping_issues,
263 })
264 }
265}
266
267#[derive(Debug, Clone)]
269pub struct MigrationPreview {
270 pub source_model_exists: bool,
272 pub target_model_exists: bool,
274 pub source_fields: Vec<String>,
276 pub target_fields: Vec<String>,
278 pub notes_to_migrate: usize,
280 pub mapping_issues: Vec<String>,
282}