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(¬es, 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(¬es, 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(¬es_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], ¬e.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(¬e.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(¬e.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(¬e.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(¬e, 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(¬e.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 ¬e_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 ¬e_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}