Skip to main content

ankit_engine/
import.rs

1//! Bulk import operations with duplicate handling.
2//!
3//! This module provides high-level import workflows that combine validation,
4//! duplicate detection, and batch operations.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use ankit_engine::{Engine, NoteBuilder};
10//! use ankit_engine::import::OnDuplicate;
11//!
12//! # async fn example() -> ankit_engine::Result<()> {
13//! let engine = Engine::new();
14//!
15//! let notes = vec![
16//!     NoteBuilder::new("Japanese", "Basic")
17//!         .field("Front", "hello")
18//!         .field("Back", "world")
19//!         .build(),
20//! ];
21//!
22//! let report = engine.import().notes(&notes, OnDuplicate::Skip).await?;
23//! println!("Added: {}, Skipped: {}", report.added, report.skipped);
24//! # Ok(())
25//! # }
26//! ```
27
28use crate::{Note, Result};
29use ankit::AnkiClient;
30
31/// Strategy for handling duplicate notes during import.
32#[derive(Debug, Clone, Copy, Default)]
33pub enum OnDuplicate {
34    /// Skip duplicate notes (default).
35    #[default]
36    Skip,
37    /// Update existing notes with new field values.
38    Update,
39    /// Allow duplicates to be created.
40    Allow,
41}
42
43/// Report of an import operation.
44#[derive(Debug, Clone, Default)]
45pub struct ImportReport {
46    /// Number of notes successfully added.
47    pub added: usize,
48    /// Number of notes skipped (duplicates).
49    pub skipped: usize,
50    /// Number of notes updated (when using OnDuplicate::Update).
51    pub updated: usize,
52    /// Number of notes that failed to import.
53    pub failed: usize,
54    /// Details about failed imports.
55    pub failures: Vec<ImportFailure>,
56}
57
58/// Details about a failed import.
59#[derive(Debug, Clone)]
60pub struct ImportFailure {
61    /// Index of the note in the input list.
62    pub index: usize,
63    /// Error message.
64    pub error: String,
65}
66
67/// Import workflow engine.
68#[derive(Debug)]
69pub struct ImportEngine<'a> {
70    client: &'a AnkiClient,
71}
72
73impl<'a> ImportEngine<'a> {
74    pub(crate) fn new(client: &'a AnkiClient) -> Self {
75        Self { client }
76    }
77
78    /// Import notes with duplicate handling.
79    ///
80    /// Validates notes, checks for duplicates, and imports in batches.
81    ///
82    /// # Arguments
83    ///
84    /// * `notes` - Notes to import
85    /// * `on_duplicate` - Strategy for handling duplicates
86    ///
87    /// # Example
88    ///
89    /// ```no_run
90    /// # use ankit_engine::{Engine, NoteBuilder};
91    /// # use ankit_engine::import::OnDuplicate;
92    /// # async fn example() -> ankit_engine::Result<()> {
93    /// let engine = Engine::new();
94    ///
95    /// let notes = vec![
96    ///     NoteBuilder::new("Default", "Basic")
97    ///         .field("Front", "Q1")
98    ///         .field("Back", "A1")
99    ///         .build(),
100    /// ];
101    ///
102    /// let report = engine.import().notes(&notes, OnDuplicate::Skip).await?;
103    /// # Ok(())
104    /// # }
105    /// ```
106    pub async fn notes(&self, notes: &[Note], on_duplicate: OnDuplicate) -> Result<ImportReport> {
107        if notes.is_empty() {
108            return Ok(ImportReport::default());
109        }
110
111        let mut report = ImportReport::default();
112
113        // Check which notes can be added
114        let can_add = self.client.notes().can_add_detailed(notes).await?;
115
116        match on_duplicate {
117            OnDuplicate::Skip => {
118                // Filter to only notes that can be added
119                let addable: Vec<_> = notes
120                    .iter()
121                    .zip(can_add.iter())
122                    .filter(|(_, result)| result.can_add)
123                    .map(|(note, _)| note.clone())
124                    .collect();
125
126                report.skipped = notes.len() - addable.len();
127
128                if !addable.is_empty() {
129                    let results = self.client.notes().add_many(&addable).await?;
130                    for (i, result) in results.iter().enumerate() {
131                        if result.is_some() {
132                            report.added += 1;
133                        } else {
134                            report.failed += 1;
135                            report.failures.push(ImportFailure {
136                                index: i,
137                                error: "Failed to add note".to_string(),
138                            });
139                        }
140                    }
141                }
142            }
143            OnDuplicate::Allow => {
144                // Add all notes, allowing duplicates
145                let notes_with_allow: Vec<_> = notes
146                    .iter()
147                    .map(|n| {
148                        let mut note = n.clone();
149                        let options = note.options.get_or_insert_with(Default::default);
150                        options.allow_duplicate = Some(true);
151                        note
152                    })
153                    .collect();
154
155                let results = self.client.notes().add_many(&notes_with_allow).await?;
156                for (i, result) in results.iter().enumerate() {
157                    if result.is_some() {
158                        report.added += 1;
159                    } else {
160                        report.failed += 1;
161                        report.failures.push(ImportFailure {
162                            index: i,
163                            error: "Failed to add note".to_string(),
164                        });
165                    }
166                }
167            }
168            OnDuplicate::Update => {
169                // For duplicates, find and update existing notes
170                for (i, (note, result)) in notes.iter().zip(can_add.iter()).enumerate() {
171                    if result.can_add {
172                        // Not a duplicate, add it
173                        match self.client.notes().add(note.clone()).await {
174                            Ok(_) => report.added += 1,
175                            Err(e) => {
176                                report.failed += 1;
177                                report.failures.push(ImportFailure {
178                                    index: i,
179                                    error: e.to_string(),
180                                });
181                            }
182                        }
183                    } else {
184                        // Duplicate - find and update
185                        // Use the first field value to search for duplicates
186                        if let Some((field_name, field_value)) = note.fields.iter().next() {
187                            let query =
188                                format!("\"{}:{}\"", field_name, field_value.replace('\"', "\\\""));
189                            match self.client.notes().find(&query).await {
190                                Ok(existing) if !existing.is_empty() => {
191                                    // Update the first match
192                                    match self
193                                        .client
194                                        .notes()
195                                        .update_fields(existing[0], &note.fields)
196                                        .await
197                                    {
198                                        Ok(_) => report.updated += 1,
199                                        Err(e) => {
200                                            report.failed += 1;
201                                            report.failures.push(ImportFailure {
202                                                index: i,
203                                                error: e.to_string(),
204                                            });
205                                        }
206                                    }
207                                }
208                                _ => {
209                                    report.skipped += 1;
210                                }
211                            }
212                        } else {
213                            report.skipped += 1;
214                        }
215                    }
216                }
217            }
218        }
219
220        Ok(report)
221    }
222
223    /// Validate notes before import without actually importing.
224    ///
225    /// Returns detailed validation results for each note.
226    pub async fn validate(&self, notes: &[Note]) -> Result<Vec<ValidationResult>> {
227        // Check model and deck existence
228        let models = self.client.models().names().await?;
229        let decks = self.client.decks().names().await?;
230
231        let mut results = Vec::with_capacity(notes.len());
232
233        for note in notes {
234            let mut errors = Vec::new();
235
236            // Check model exists
237            if !models.contains(&note.model_name) {
238                errors.push(format!("Model '{}' not found", note.model_name));
239            } else {
240                // Check fields match model
241                let model_fields = self.client.models().field_names(&note.model_name).await?;
242                for field_name in note.fields.keys() {
243                    if !model_fields.contains(field_name) {
244                        errors.push(format!("Unknown field '{}'", field_name));
245                    }
246                }
247            }
248
249            // Check deck exists
250            if !decks.contains(&note.deck_name) {
251                errors.push(format!("Deck '{}' not found", note.deck_name));
252            }
253
254            results.push(ValidationResult {
255                valid: errors.is_empty(),
256                errors,
257            });
258        }
259
260        Ok(results)
261    }
262
263    /// Smart add a single note with duplicate checking and tag suggestions.
264    ///
265    /// Combines validation, duplicate detection, and tag suggestions into
266    /// a single atomic operation.
267    ///
268    /// # Arguments
269    ///
270    /// * `note` - Note to add
271    /// * `options` - Options controlling duplicate handling and tag suggestions
272    ///
273    /// # Example
274    ///
275    /// ```no_run
276    /// # use ankit_engine::{Engine, NoteBuilder};
277    /// # use ankit_engine::import::SmartAddOptions;
278    /// # async fn example() -> ankit_engine::Result<()> {
279    /// let engine = Engine::new();
280    ///
281    /// let note = NoteBuilder::new("Japanese", "Basic")
282    ///     .field("Front", "hello")
283    ///     .field("Back", "world")
284    ///     .build();
285    ///
286    /// let result = engine.import().smart_add(&note, SmartAddOptions::default()).await?;
287    ///
288    /// match result.status {
289    ///     ankit_engine::import::SmartAddStatus::Added => {
290    ///         println!("Added note: {:?}", result.note_id);
291    ///     }
292    ///     ankit_engine::import::SmartAddStatus::RejectedDuplicate { existing_id } => {
293    ///         println!("Duplicate of note {}", existing_id);
294    ///     }
295    ///     _ => {}
296    /// }
297    ///
298    /// if !result.suggested_tags.is_empty() {
299    ///     println!("Suggested tags: {:?}", result.suggested_tags);
300    /// }
301    /// # Ok(())
302    /// # }
303    /// ```
304    pub async fn smart_add(&self, note: &Note, options: SmartAddOptions) -> Result<SmartAddResult> {
305        let mut result = SmartAddResult {
306            note_id: None,
307            status: SmartAddStatus::Added,
308            suggested_tags: Vec::new(),
309            similar_notes: Vec::new(),
310        };
311
312        // Check for empty fields if requested
313        if options.check_empty_fields {
314            let empty_fields: Vec<String> = note
315                .fields
316                .iter()
317                .filter(|(_, v)| v.trim().is_empty())
318                .map(|(k, _)| k.clone())
319                .collect();
320
321            if !empty_fields.is_empty() {
322                result.status = SmartAddStatus::RejectedEmptyFields {
323                    fields: empty_fields,
324                };
325                return Ok(result);
326            }
327        }
328
329        // Validate the note
330        let validation = self.validate(std::slice::from_ref(note)).await?;
331        if let Some(v) = validation.first() {
332            if !v.valid {
333                result.status = SmartAddStatus::RejectedInvalid {
334                    errors: v.errors.clone(),
335                };
336                return Ok(result);
337            }
338        }
339
340        // Check for duplicates using the model's first field
341        if options.check_duplicates {
342            // Get the model's field names to find the canonical "first" field
343            let model_fields = self.client.models().field_names(&note.model_name).await?;
344            let first_field_name = model_fields.first().cloned();
345
346            if let Some(field_name) = first_field_name {
347                if let Some(field_value) = note.fields.get(&field_name) {
348                    if !field_value.trim().is_empty() {
349                        // Search for notes with same first field value in the same deck
350                        let query = format!(
351                            "deck:\"{}\" \"{}:{}\"",
352                            note.deck_name,
353                            field_name,
354                            field_value.replace('\"', "\\\"")
355                        );
356
357                        let existing = self.client.notes().find(&query).await?;
358
359                        if !existing.is_empty() {
360                            result.similar_notes = existing.clone();
361
362                            // Collect tags from similar notes for suggestions
363                            if options.suggest_tags {
364                                let note_infos = self.client.notes().info(&existing).await?;
365                                let mut tag_counts: std::collections::HashMap<String, usize> =
366                                    std::collections::HashMap::new();
367
368                                for info in &note_infos {
369                                    for tag in &info.tags {
370                                        *tag_counts.entry(tag.clone()).or_insert(0) += 1;
371                                    }
372                                }
373
374                                // Sort by frequency and take top suggestions
375                                let mut tags: Vec<_> = tag_counts.into_iter().collect();
376                                tags.sort_by(|a, b| b.1.cmp(&a.1));
377                                result.suggested_tags = tags
378                                    .into_iter()
379                                    .take(5)
380                                    .map(|(tag, _)| tag)
381                                    .filter(|t| !note.tags.contains(t))
382                                    .collect();
383                            }
384
385                            if options.reject_on_duplicate {
386                                result.status = SmartAddStatus::RejectedDuplicate {
387                                    existing_id: existing[0],
388                                };
389                                return Ok(result);
390                            }
391                        }
392                    }
393                }
394            }
395        }
396
397        // Suggest tags from similar content even if no exact duplicates
398        if options.suggest_tags && result.suggested_tags.is_empty() {
399            // Search for notes in the same deck with the same model
400            let query = format!("deck:\"{}\" note:\"{}\"", note.deck_name, note.model_name);
401            let similar = self.client.notes().find(&query).await?;
402
403            if !similar.is_empty() {
404                // Sample up to 50 notes for tag suggestions
405                let sample: Vec<_> = similar.into_iter().take(50).collect();
406                let note_infos = self.client.notes().info(&sample).await?;
407
408                let mut tag_counts: std::collections::HashMap<String, usize> =
409                    std::collections::HashMap::new();
410
411                for info in &note_infos {
412                    for tag in &info.tags {
413                        *tag_counts.entry(tag.clone()).or_insert(0) += 1;
414                    }
415                }
416
417                // Sort by frequency and take top suggestions
418                let mut tags: Vec<_> = tag_counts.into_iter().collect();
419                tags.sort_by(|a, b| b.1.cmp(&a.1));
420                result.suggested_tags = tags
421                    .into_iter()
422                    .take(5)
423                    .map(|(tag, _)| tag)
424                    .filter(|t| !note.tags.contains(t))
425                    .collect();
426            }
427        }
428
429        // Add the note
430        let mut note_to_add = note.clone();
431
432        // If we found duplicates but aren't rejecting, allow the duplicate
433        if !result.similar_notes.is_empty() && !options.reject_on_duplicate {
434            let options = note_to_add.options.get_or_insert_with(Default::default);
435            options.allow_duplicate = Some(true);
436        }
437
438        match self.client.notes().add(note_to_add).await {
439            Ok(note_id) => {
440                result.note_id = Some(note_id);
441                if !result.similar_notes.is_empty() {
442                    result.status = SmartAddStatus::AddedWithWarning {
443                        warning: format!(
444                            "Potential duplicate of {} existing note(s)",
445                            result.similar_notes.len()
446                        ),
447                    };
448                } else {
449                    result.status = SmartAddStatus::Added;
450                }
451            }
452            Err(e) => {
453                result.status = SmartAddStatus::RejectedInvalid {
454                    errors: vec![e.to_string()],
455                };
456            }
457        }
458
459        Ok(result)
460    }
461}
462
463/// Result of validating a single note.
464#[derive(Debug, Clone)]
465pub struct ValidationResult {
466    /// Whether the note is valid.
467    pub valid: bool,
468    /// Validation errors, if any.
469    pub errors: Vec<String>,
470}
471
472/// Options for smart add operation.
473#[derive(Debug, Clone)]
474pub struct SmartAddOptions {
475    /// Check for duplicate notes before adding.
476    pub check_duplicates: bool,
477    /// Suggest tags based on similar notes.
478    pub suggest_tags: bool,
479    /// Reject the note if a duplicate is found (otherwise add with warning).
480    pub reject_on_duplicate: bool,
481    /// Check for empty required fields.
482    pub check_empty_fields: bool,
483}
484
485impl Default for SmartAddOptions {
486    fn default() -> Self {
487        Self {
488            check_duplicates: true,
489            suggest_tags: true,
490            reject_on_duplicate: true,
491            check_empty_fields: true,
492        }
493    }
494}
495
496/// Status of a smart add operation.
497#[derive(Debug, Clone)]
498pub enum SmartAddStatus {
499    /// Note was successfully added.
500    Added,
501    /// Note was added despite being a potential duplicate.
502    AddedWithWarning {
503        /// Reason for the warning.
504        warning: String,
505    },
506    /// Note was rejected as a duplicate.
507    RejectedDuplicate {
508        /// ID of the existing duplicate note.
509        existing_id: i64,
510    },
511    /// Note was rejected due to empty required fields.
512    RejectedEmptyFields {
513        /// Names of empty fields.
514        fields: Vec<String>,
515    },
516    /// Note was rejected due to validation errors.
517    RejectedInvalid {
518        /// Validation error messages.
519        errors: Vec<String>,
520    },
521}
522
523/// Result of a smart add operation.
524#[derive(Debug, Clone)]
525pub struct SmartAddResult {
526    /// The note ID if successfully added, None if rejected.
527    pub note_id: Option<i64>,
528    /// Status of the operation.
529    pub status: SmartAddStatus,
530    /// Suggested tags based on similar notes.
531    pub suggested_tags: Vec<String>,
532    /// IDs of similar notes found (potential duplicates).
533    pub similar_notes: Vec<i64>,
534}