> {
+ decks
+ .iter()
+ .filter_map(|deck| deck.config_id())
+ .unique()
+ .filter(|config_id| *config_id != DeckConfigId(1))
+ .map(|config_id| {
+ self.storage
+ .get_deck_config(config_id)?
+ .ok_or(AnkiError::NotFound)
+ })
+ .collect()
+ }
+}
diff --git a/rslib/src/import_export/insert.rs b/rslib/src/import_export/insert.rs
new file mode 100644
index 000000000..21ab1be1c
--- /dev/null
+++ b/rslib/src/import_export/insert.rs
@@ -0,0 +1,62 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use super::gather::ExchangeData;
+use crate::{prelude::*, revlog::RevlogEntry};
+
+impl Collection {
+ pub(super) fn insert_data(&mut self, data: &ExchangeData) -> Result<()> {
+ self.transact_no_undo(|col| {
+ col.insert_decks(&data.decks)?;
+ col.insert_notes(&data.notes)?;
+ col.insert_cards(&data.cards)?;
+ col.insert_notetypes(&data.notetypes)?;
+ col.insert_revlog(&data.revlog)?;
+ col.insert_deck_configs(&data.deck_configs)
+ })
+ }
+
+ fn insert_decks(&self, decks: &[Deck]) -> Result<()> {
+ for deck in decks {
+ self.storage.add_or_update_deck_with_existing_id(deck)?;
+ }
+ Ok(())
+ }
+
+ fn insert_notes(&self, notes: &[Note]) -> Result<()> {
+ for note in notes {
+ self.storage.add_or_update_note(note)?;
+ }
+ Ok(())
+ }
+
+ fn insert_cards(&self, cards: &[Card]) -> Result<()> {
+ for card in cards {
+ self.storage.add_or_update_card(card)?;
+ }
+ Ok(())
+ }
+
+ fn insert_notetypes(&self, notetypes: &[Notetype]) -> Result<()> {
+ for notetype in notetypes {
+ self.storage
+ .add_or_update_notetype_with_existing_id(notetype)?;
+ }
+ Ok(())
+ }
+
+ fn insert_revlog(&self, revlog: &[RevlogEntry]) -> Result<()> {
+ for entry in revlog {
+ self.storage.add_revlog_entry(entry, false)?;
+ }
+ Ok(())
+ }
+
+ fn insert_deck_configs(&self, configs: &[DeckConfig]) -> Result<()> {
+ for config in configs {
+ self.storage
+ .add_or_update_deck_config_with_existing_id(config)?;
+ }
+ Ok(())
+ }
+}
diff --git a/rslib/src/import_export/mod.rs b/rslib/src/import_export/mod.rs
index 994d93101..b1e2944af 100644
--- a/rslib/src/import_export/mod.rs
+++ b/rslib/src/import_export/mod.rs
@@ -1,10 +1,87 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+mod gather;
+mod insert;
pub mod package;
+use std::marker::PhantomData;
+
+use crate::prelude::*;
+
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ImportProgress {
- Collection,
+ File,
Media(usize),
+ MediaCheck(usize),
+ Notes(usize),
+}
+
+/// Wrapper around a progress function, usually passed by the [crate::backend::Backend],
+/// to make repeated calls more ergonomic.
+pub(crate) struct IncrementableProgress(Box bool>);
+
+impl IncrementableProgress
{
+ /// `progress_fn: (progress, throttle) -> should_continue`
+ pub(crate) fn new(progress_fn: impl 'static + FnMut(P, bool) -> bool) -> Self {
+ Self(Box::new(progress_fn))
+ }
+
+ /// Returns an [Incrementor] with an `increment()` function for use in loops.
+ pub(crate) fn incrementor<'inc, 'progress: 'inc, 'map: 'inc>(
+ &'progress mut self,
+ mut count_map: impl 'map + FnMut(usize) -> P,
+ ) -> Incrementor<'inc, impl FnMut(usize) -> Result<()> + 'inc> {
+ Incrementor::new(move |u| self.update(count_map(u), true))
+ }
+
+ /// Manually triggers an update.
+ /// Returns [AnkiError::Interrupted] if the operation should be cancelled.
+ pub(crate) fn call(&mut self, progress: P) -> Result<()> {
+ self.update(progress, false)
+ }
+
+ fn update(&mut self, progress: P, throttle: bool) -> Result<()> {
+ if (self.0)(progress, throttle) {
+ Ok(())
+ } else {
+ Err(AnkiError::Interrupted)
+ }
+ }
+
+ /// Stopgap for returning a progress fn compliant with the media code.
+ pub(crate) fn media_db_fn(
+ &mut self,
+ count_map: impl 'static + Fn(usize) -> P,
+ ) -> Result bool + '_> {
+ Ok(move |count| (self.0)(count_map(count), true))
+ }
+}
+
+pub(crate) struct Incrementor<'f, F: 'f + FnMut(usize) -> Result<()>> {
+ update_fn: F,
+ count: usize,
+ update_interval: usize,
+ _phantom: PhantomData<&'f ()>,
+}
+
+impl<'f, F: 'f + FnMut(usize) -> Result<()>> Incrementor<'f, F> {
+ fn new(update_fn: F) -> Self {
+ Self {
+ update_fn,
+ count: 0,
+ update_interval: 17,
+ _phantom: PhantomData,
+ }
+ }
+
+ /// Increments the progress counter, periodically triggering an update.
+ /// Returns [AnkiError::Interrupted] if the operation should be cancelled.
+ pub(crate) fn increment(&mut self) -> Result<()> {
+ self.count += 1;
+ if self.count % self.update_interval != 0 {
+ return Ok(());
+ }
+ (self.update_fn)(self.count)
+ }
}
diff --git a/rslib/src/import_export/package/apkg/export.rs b/rslib/src/import_export/package/apkg/export.rs
new file mode 100644
index 000000000..9b797893d
--- /dev/null
+++ b/rslib/src/import_export/package/apkg/export.rs
@@ -0,0 +1,106 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use std::{
+ collections::HashSet,
+ path::{Path, PathBuf},
+};
+
+use tempfile::NamedTempFile;
+
+use crate::{
+ collection::CollectionBuilder,
+ import_export::{
+ gather::ExchangeData,
+ package::{
+ colpkg::export::{export_collection, MediaIter},
+ Meta,
+ },
+ IncrementableProgress,
+ },
+ io::{atomic_rename, tempfile_in_parent_of},
+ prelude::*,
+};
+
+impl Collection {
+ /// Returns number of exported notes.
+ #[allow(clippy::too_many_arguments)]
+ pub fn export_apkg(
+ &mut self,
+ out_path: impl AsRef,
+ search: impl TryIntoSearch,
+ with_scheduling: bool,
+ with_media: bool,
+ legacy: bool,
+ media_fn: Option) -> MediaIter>>,
+ progress_fn: impl 'static + FnMut(usize, bool) -> bool,
+ ) -> Result {
+ let mut progress = IncrementableProgress::new(progress_fn);
+ let temp_apkg = tempfile_in_parent_of(out_path.as_ref())?;
+ let mut temp_col = NamedTempFile::new()?;
+ let temp_col_path = temp_col
+ .path()
+ .to_str()
+ .ok_or_else(|| AnkiError::IoError("tempfile with non-unicode name".into()))?;
+ let meta = if legacy {
+ Meta::new_legacy()
+ } else {
+ Meta::new()
+ };
+ let data = self.export_into_collection_file(
+ &meta,
+ temp_col_path,
+ search,
+ with_scheduling,
+ with_media,
+ )?;
+
+ let media = if let Some(media_fn) = media_fn {
+ media_fn(data.media_filenames)
+ } else {
+ MediaIter::from_file_list(data.media_filenames, self.media_folder.clone())
+ };
+ let col_size = temp_col.as_file().metadata()?.len() as usize;
+
+ export_collection(
+ meta,
+ temp_apkg.path(),
+ &mut temp_col,
+ col_size,
+ media,
+ &self.tr,
+ &mut progress,
+ )?;
+ atomic_rename(temp_apkg, out_path.as_ref(), true)?;
+ Ok(data.notes.len())
+ }
+
+ fn export_into_collection_file(
+ &mut self,
+ meta: &Meta,
+ path: &str,
+ search: impl TryIntoSearch,
+ with_scheduling: bool,
+ with_media: bool,
+ ) -> Result {
+ let mut data = ExchangeData::default();
+ data.gather_data(self, search, with_scheduling)?;
+ if with_media {
+ data.gather_media_names();
+ }
+
+ let mut temp_col = Collection::new_minimal(path)?;
+ temp_col.insert_data(&data)?;
+ temp_col.set_creation_stamp(self.storage.creation_stamp()?)?;
+ temp_col.set_creation_utc_offset(data.creation_utc_offset)?;
+ temp_col.close(Some(meta.schema_version()))?;
+
+ Ok(data)
+ }
+
+ fn new_minimal(path: impl Into) -> Result {
+ let col = CollectionBuilder::new(path).build()?;
+ col.storage.db.execute_batch("DELETE FROM notetypes")?;
+ Ok(col)
+ }
+}
diff --git a/rslib/src/import_export/package/apkg/import/cards.rs b/rslib/src/import_export/package/apkg/import/cards.rs
new file mode 100644
index 000000000..6a87510c1
--- /dev/null
+++ b/rslib/src/import_export/package/apkg/import/cards.rs
@@ -0,0 +1,179 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use std::{
+ collections::{HashMap, HashSet},
+ mem,
+};
+
+use super::Context;
+use crate::{
+ card::{CardQueue, CardType},
+ config::SchedulerVersion,
+ prelude::*,
+ revlog::RevlogEntry,
+};
+
+type CardAsNidAndOrd = (NoteId, u16);
+
+struct CardContext<'a> {
+ target_col: &'a mut Collection,
+ usn: Usn,
+
+ imported_notes: &'a HashMap,
+ remapped_decks: &'a HashMap,
+
+ /// The number of days the source collection is ahead of the target collection
+ collection_delta: i32,
+ scheduler_version: SchedulerVersion,
+ existing_cards: HashSet,
+ existing_card_ids: HashSet,
+
+ imported_cards: HashMap,
+}
+
+impl<'c> CardContext<'c> {
+ fn new<'a: 'c>(
+ usn: Usn,
+ days_elapsed: u32,
+ target_col: &'a mut Collection,
+ imported_notes: &'a HashMap,
+ imported_decks: &'a HashMap,
+ ) -> Result {
+ let existing_cards = target_col.storage.all_cards_as_nid_and_ord()?;
+ let collection_delta = target_col.collection_delta(days_elapsed)?;
+ let scheduler_version = target_col.scheduler_info()?.version;
+ let existing_card_ids = target_col.storage.get_all_card_ids()?;
+ Ok(Self {
+ target_col,
+ usn,
+ imported_notes,
+ remapped_decks: imported_decks,
+ existing_cards,
+ collection_delta,
+ scheduler_version,
+ existing_card_ids,
+ imported_cards: HashMap::new(),
+ })
+ }
+}
+
+impl Collection {
+ /// How much `days_elapsed` is ahead of this collection.
+ fn collection_delta(&mut self, days_elapsed: u32) -> Result {
+ Ok(days_elapsed as i32 - self.timing_today()?.days_elapsed as i32)
+ }
+}
+
+impl Context<'_> {
+ pub(super) fn import_cards_and_revlog(
+ &mut self,
+ imported_notes: &HashMap,
+ imported_decks: &HashMap,
+ ) -> Result<()> {
+ let mut ctx = CardContext::new(
+ self.usn,
+ self.data.days_elapsed,
+ self.target_col,
+ imported_notes,
+ imported_decks,
+ )?;
+ ctx.import_cards(mem::take(&mut self.data.cards))?;
+ ctx.import_revlog(mem::take(&mut self.data.revlog))
+ }
+}
+
+impl CardContext<'_> {
+ fn import_cards(&mut self, mut cards: Vec) -> Result<()> {
+ for card in &mut cards {
+ if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) {
+ self.add_card(card)?;
+ }
+ // TODO: could update existing card
+ }
+ Ok(())
+ }
+
+ fn import_revlog(&mut self, revlog: Vec) -> Result<()> {
+ for mut entry in revlog {
+ if let Some(cid) = self.imported_cards.get(&entry.cid) {
+ entry.cid = *cid;
+ entry.usn = self.usn;
+ self.target_col.add_revlog_entry_if_unique_undoable(entry)?;
+ }
+ }
+ Ok(())
+ }
+
+ fn map_to_imported_note(&self, card: &mut Card) -> bool {
+ if let Some(nid) = self.imported_notes.get(&card.note_id) {
+ card.note_id = *nid;
+ true
+ } else {
+ false
+ }
+ }
+
+ fn card_ordinal_already_exists(&self, card: &Card) -> bool {
+ self.existing_cards
+ .contains(&(card.note_id, card.template_idx))
+ }
+
+ fn add_card(&mut self, card: &mut Card) -> Result<()> {
+ card.usn = self.usn;
+ self.remap_deck_id(card);
+ card.shift_collection_relative_dates(self.collection_delta);
+ card.maybe_remove_from_filtered_deck(self.scheduler_version);
+ let old_id = self.uniquify_card_id(card);
+
+ self.target_col.add_card_if_unique_undoable(card)?;
+ self.existing_card_ids.insert(card.id);
+ self.imported_cards.insert(old_id, card.id);
+
+ Ok(())
+ }
+
+ fn uniquify_card_id(&mut self, card: &mut Card) -> CardId {
+ let original = card.id;
+ while self.existing_card_ids.contains(&card.id) {
+ card.id.0 += 999;
+ }
+ original
+ }
+
+ fn remap_deck_id(&self, card: &mut Card) {
+ if let Some(did) = self.remapped_decks.get(&card.deck_id) {
+ card.deck_id = *did;
+ }
+ }
+}
+
+impl Card {
+ /// `delta` is the number days the card's source collection is ahead of the
+ /// target collection.
+ fn shift_collection_relative_dates(&mut self, delta: i32) {
+ if self.due_in_days_since_collection_creation() {
+ self.due -= delta;
+ }
+ if self.original_due_in_days_since_collection_creation() && self.original_due != 0 {
+ self.original_due -= delta;
+ }
+ }
+
+ fn due_in_days_since_collection_creation(&self) -> bool {
+ matches!(self.queue, CardQueue::Review | CardQueue::DayLearn)
+ || self.ctype == CardType::Review
+ }
+
+ fn original_due_in_days_since_collection_creation(&self) -> bool {
+ self.ctype == CardType::Review
+ }
+
+ fn maybe_remove_from_filtered_deck(&mut self, version: SchedulerVersion) {
+ if self.is_filtered() {
+ // instead of moving between decks, the deck is converted to a regular one
+ self.original_deck_id = self.deck_id;
+ self.remove_from_filtered_deck_restoring_queue(version);
+ }
+ }
+}
diff --git a/rslib/src/import_export/package/apkg/import/decks.rs b/rslib/src/import_export/package/apkg/import/decks.rs
new file mode 100644
index 000000000..c9717963f
--- /dev/null
+++ b/rslib/src/import_export/package/apkg/import/decks.rs
@@ -0,0 +1,212 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use std::{collections::HashMap, mem};
+
+use super::Context;
+use crate::{decks::NormalDeck, prelude::*};
+
+struct DeckContext<'d> {
+ target_col: &'d mut Collection,
+ usn: Usn,
+ renamed_parents: Vec<(String, String)>,
+ imported_decks: HashMap,
+ unique_suffix: String,
+}
+
+impl<'d> DeckContext<'d> {
+ fn new<'a: 'd>(target_col: &'a mut Collection, usn: Usn) -> Self {
+ Self {
+ target_col,
+ usn,
+ renamed_parents: Vec::new(),
+ imported_decks: HashMap::new(),
+ unique_suffix: TimestampSecs::now().to_string(),
+ }
+ }
+}
+
+impl Context<'_> {
+ pub(super) fn import_decks_and_configs(&mut self) -> Result> {
+ let mut ctx = DeckContext::new(self.target_col, self.usn);
+ ctx.import_deck_configs(mem::take(&mut self.data.deck_configs))?;
+ ctx.import_decks(mem::take(&mut self.data.decks))?;
+ Ok(ctx.imported_decks)
+ }
+}
+
+impl DeckContext<'_> {
+ fn import_deck_configs(&mut self, mut configs: Vec) -> Result<()> {
+ for config in &mut configs {
+ config.usn = self.usn;
+ self.target_col.add_deck_config_if_unique_undoable(config)?;
+ }
+ Ok(())
+ }
+
+ fn import_decks(&mut self, mut decks: Vec) -> Result<()> {
+ // ensure parents are seen before children
+ decks.sort_unstable_by_key(|deck| deck.level());
+ for deck in &mut decks {
+ self.prepare_deck(deck);
+ self.import_deck(deck)?;
+ }
+ Ok(())
+ }
+
+ fn prepare_deck(&mut self, deck: &mut Deck) {
+ self.maybe_reparent(deck);
+ if deck.is_filtered() {
+ deck.kind = DeckKind::Normal(NormalDeck {
+ config_id: 1,
+ ..Default::default()
+ });
+ }
+ }
+
+ fn import_deck(&mut self, deck: &mut Deck) -> Result<()> {
+ if let Some(original) = self.get_deck_by_name(deck)? {
+ if original.is_filtered() {
+ self.uniquify_name(deck);
+ self.add_deck(deck)
+ } else {
+ self.update_deck(deck, original)
+ }
+ } else {
+ self.ensure_valid_first_existing_parent(deck)?;
+ self.add_deck(deck)
+ }
+ }
+
+ fn maybe_reparent(&self, deck: &mut Deck) {
+ if let Some(new_name) = self.reparented_name(deck.name.as_native_str()) {
+ deck.name = NativeDeckName::from_native_str(new_name);
+ }
+ }
+
+ fn reparented_name(&self, name: &str) -> Option {
+ self.renamed_parents
+ .iter()
+ .find_map(|(old_parent, new_parent)| {
+ name.starts_with(old_parent)
+ .then(|| name.replacen(old_parent, new_parent, 1))
+ })
+ }
+
+ fn get_deck_by_name(&mut self, deck: &Deck) -> Result