Skip to main content

ankit_engine/
migrate.rs

1//! Note type migration operations.
2//!
3//! This module provides workflows for migrating notes from one
4//! note type (model) to another with field mapping.
5
6use crate::{Error, NoteBuilder, Result};
7use ankit::AnkiClient;
8use std::collections::HashMap;
9
10/// Configuration for a note type migration.
11#[derive(Debug, Clone)]
12pub struct MigrationConfig {
13    /// Source model name.
14    pub source_model: String,
15    /// Target model name.
16    pub target_model: String,
17    /// Field mapping: source field -> target field.
18    pub field_mapping: HashMap<String, String>,
19    /// Target deck (if None, keeps original deck).
20    pub target_deck: Option<String>,
21    /// Whether to delete source notes after migration.
22    pub delete_source: bool,
23    /// Tags to add to migrated notes.
24    pub add_tags: Vec<String>,
25}
26
27/// Report of a migration operation.
28#[derive(Debug, Clone, Default)]
29pub struct MigrationReport {
30    /// Number of notes successfully migrated.
31    pub migrated: usize,
32    /// Number of notes that failed to migrate.
33    pub failed: usize,
34    /// Number of source notes deleted.
35    pub deleted: usize,
36    /// Errors encountered during migration.
37    pub errors: Vec<MigrationError>,
38}
39
40/// Error during migration of a single note.
41#[derive(Debug, Clone)]
42pub struct MigrationError {
43    /// The source note ID.
44    pub note_id: i64,
45    /// The error message.
46    pub error: String,
47}
48
49/// Migration workflow engine.
50#[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    /// Migrate notes from one model to another.
61    ///
62    /// # Arguments
63    ///
64    /// * `config` - Migration configuration
65    /// * `query` - Optional query to filter notes (None = all notes of source model)
66    ///
67    /// # Example
68    ///
69    /// ```no_run
70    /// # use ankit_engine::Engine;
71    /// # use ankit_engine::migrate::MigrationConfig;
72    /// # use std::collections::HashMap;
73    /// # async fn example() -> ankit_engine::Result<()> {
74    /// let engine = Engine::new();
75    ///
76    /// let mut field_mapping = HashMap::new();
77    /// field_mapping.insert("Front".to_string(), "Question".to_string());
78    /// field_mapping.insert("Back".to_string(), "Answer".to_string());
79    ///
80    /// let config = MigrationConfig {
81    ///     source_model: "Basic".to_string(),
82    ///     target_model: "Basic (and reversed card)".to_string(),
83    ///     field_mapping,
84    ///     target_deck: None,
85    ///     delete_source: false,
86    ///     add_tags: vec!["migrated".to_string()],
87    /// };
88    ///
89    /// let report = engine.migrate().notes(config, None).await?;
90    /// # Ok(())
91    /// # }
92    /// ```
93    pub async fn notes(
94        &self,
95        config: MigrationConfig,
96        query: Option<&str>,
97    ) -> Result<MigrationReport> {
98        // Verify models exist
99        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        // Verify target model has all mapped fields
108        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        // Find notes to migrate
123        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(&note_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            // Map fields
137            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            // Determine deck
145            // Get deck from first card of source note
146            let deck = if let Some(ref deck) = config.target_deck {
147                deck.clone()
148            } else {
149                // Try to get the deck from the source note's cards
150                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            // Build new note
162            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            // Add original tags plus new tags
168            builder = builder.tags(info.tags.iter().cloned());
169            builder = builder.tags(config.add_tags.iter().cloned());
170
171            // Allow duplicate since we're migrating
172            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        // Delete source notes if requested
192        if !notes_to_delete.is_empty() {
193            self.client.notes().delete(&notes_to_delete).await?;
194            report.deleted = notes_to_delete.len();
195        }
196
197        Ok(report)
198    }
199
200    /// Preview a migration without making changes.
201    ///
202    /// Returns information about what would be migrated.
203    pub async fn preview(
204        &self,
205        config: &MigrationConfig,
206        query: Option<&str>,
207    ) -> Result<MigrationPreview> {
208        // Verify models exist
209        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        // Get field info
214        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        // Check field mapping
233        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        // Count notes
244        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/// Preview of a migration operation.
268#[derive(Debug, Clone)]
269pub struct MigrationPreview {
270    /// Whether the source model exists.
271    pub source_model_exists: bool,
272    /// Whether the target model exists.
273    pub target_model_exists: bool,
274    /// Fields in the source model.
275    pub source_fields: Vec<String>,
276    /// Fields in the target model.
277    pub target_fields: Vec<String>,
278    /// Number of notes that would be migrated.
279    pub notes_to_migrate: usize,
280    /// Issues with the field mapping.
281    pub mapping_issues: Vec<String>,
282}