Allow im-/exporting with or without deck configs (#2804)

* Allow im-/exporting with or without deck configs

Closes #2777.

* Enable webengine remote debugging in launch.json

* Reset deck limits and counts based on scheduling

Also:
- Fix `deck.common` not being reset.
- Apply all logic only depending on the source collection in the
gathering stage.
- Skip checking for scheduling and only act based on whether the call
wants scheduling. Preservation of filtered decks also depends on all
original decks being included.
- Fix check_ids() not covering revlog.

* Fix importing legacy filtered decks w/o scheduling

* Disable 'include deck options' by default, and fix tab order (dae)

* deck options > deck presets (dae)
This commit is contained in:
RumovZ 2023-11-13 04:54:41 +01:00 committed by GitHub
parent d9e5c85686
commit f200d6248e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 204 additions and 153 deletions

View file

@ -18,7 +18,9 @@
"env": { "env": {
"PYTHONWARNINGS": "default", "PYTHONWARNINGS": "default",
"PYTHONPYCACHEPREFIX": "out/pycache", "PYTHONPYCACHEPREFIX": "out/pycache",
"ANKIDEV": "1" "ANKIDEV": "1",
"QTWEBENGINE_REMOTE_DEBUGGING": "8080",
"QTWEBENGINE_CHROMIUM_FLAGS": "--remote-allow-origins=http://localhost:8080"
}, },
"justMyCode": true, "justMyCode": true,
"preLaunchTask": "ninja" "preLaunchTask": "ninja"

View file

@ -13,6 +13,7 @@ exporting-include = <b>Include</b>:
exporting-include-html-and-media-references = Include HTML and media references exporting-include-html-and-media-references = Include HTML and media references
exporting-include-media = Include media exporting-include-media = Include media
exporting-include-scheduling-information = Include scheduling information exporting-include-scheduling-information = Include scheduling information
exporting-include-deck-configs = Include deck presets
exporting-include-tags = Include tags exporting-include-tags = Include tags
exporting-support-older-anki-versions = Support older Anki versions (slower/larger files) exporting-support-older-anki-versions = Support older Anki versions (slower/larger files)
exporting-notes-in-plain-text = Notes in Plain Text exporting-notes-in-plain-text = Notes in Plain Text

View file

@ -50,11 +50,15 @@ importing-notes-skipped-as-theyre-already-in = Notes skipped, as up-to-date copi
importing-notes-skipped-update-due-to-notetype = Notes not updated, as notetype has been modified since you first imported the notes: { $val } importing-notes-skipped-update-due-to-notetype = Notes not updated, as notetype has been modified since you first imported the notes: { $val }
importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val } importing-notes-updated-as-file-had-newer = Notes updated, as file had newer version: { $val }
importing-include-reviews = Include reviews importing-include-reviews = Include reviews
importing-also-import-progress = Also import any learning progress importing-also-import-progress = Import any learning progress
importing-with-deck-configs = Import any deck presets
importing-updates = Updates importing-updates = Updates
importing-include-reviews-help = importing-include-reviews-help =
If enabled, any previous reviews that the deck sharer included will also be imported. If enabled, any previous reviews that the deck sharer included will also be imported.
Otherwise, all cards will be imported as new cards. Otherwise, all cards will be imported as new cards.
importing-with-deck-configs-help =
If enabled, any deck options that the deck sharer included will also be imported.
Otherwise, all decks will be assigned the default preset.
importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip) importing-packaged-anki-deckcollection-apkg-colpkg-zip = Packaged Anki Deck/Collection (*.apkg *.colpkg *.zip)
importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz) importing-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
# the '|' character # the '|' character

View file

@ -58,6 +58,7 @@ message ImportAnkiPackageOptions {
ImportAnkiPackageUpdateCondition update_notes = 2; ImportAnkiPackageUpdateCondition update_notes = 2;
ImportAnkiPackageUpdateCondition update_notetypes = 3; ImportAnkiPackageUpdateCondition update_notetypes = 3;
bool with_scheduling = 4; bool with_scheduling = 4;
bool with_deck_configs = 5;
} }
message ImportAnkiPackageRequest { message ImportAnkiPackageRequest {
@ -88,10 +89,15 @@ message ImportResponse {
message ExportAnkiPackageRequest { message ExportAnkiPackageRequest {
string out_path = 1; string out_path = 1;
bool with_scheduling = 2; ExportAnkiPackageOptions options = 2;
ExportLimit limit = 3;
}
message ExportAnkiPackageOptions {
bool with_scheduling = 1;
bool with_deck_configs = 2;
bool with_media = 3; bool with_media = 3;
bool legacy = 4; bool legacy = 4;
ExportLimit limit = 5;
} }
message PackageMetadata { message PackageMetadata {

View file

@ -42,6 +42,7 @@ BrowserColumns = search_pb2.BrowserColumns
StripHtmlMode = card_rendering_pb2.StripHtmlRequest StripHtmlMode = card_rendering_pb2.StripHtmlRequest
ImportLogWithChanges = import_export_pb2.ImportResponse ImportLogWithChanges = import_export_pb2.ImportResponse
ImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest ImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest
ExportAnkiPackageOptions = import_export_pb2.ExportAnkiPackageOptions
ImportCsvRequest = import_export_pb2.ImportCsvRequest ImportCsvRequest = import_export_pb2.ImportCsvRequest
CsvMetadata = import_export_pb2.CsvMetadata CsvMetadata = import_export_pb2.CsvMetadata
DupeResolution = CsvMetadata.DupeResolution DupeResolution = CsvMetadata.DupeResolution
@ -361,19 +362,11 @@ class Collection(DeprecatedNamesMixin):
return ImportLogWithChanges.FromString(log) return ImportLogWithChanges.FromString(log)
def export_anki_package( def export_anki_package(
self, self, *, out_path: str, options: ExportAnkiPackageOptions, limit: ExportLimit
*,
out_path: str,
limit: ExportLimit,
with_scheduling: bool,
with_media: bool,
legacy_support: bool,
) -> int: ) -> int:
return self._backend.export_anki_package( return self._backend.export_anki_package(
out_path=out_path, out_path=out_path,
with_scheduling=with_scheduling, options=options,
with_media=with_media,
legacy=legacy_support,
limit=pb_export_limit(limit), limit=pb_export_limit(limit),
) )

View file

@ -67,6 +67,16 @@
</property> </property>
</widget> </widget>
</item> </item>
<item alignment="Qt::AlignLeft">
<widget class="QCheckBox" name="include_deck_configs">
<property name="text">
<string>exporting_include_deck_configs</string>
</property>
<property name="checked">
<bool>false</bool>
</property>
</widget>
</item>
<item alignment="Qt::AlignLeft"> <item alignment="Qt::AlignLeft">
<widget class="QCheckBox" name="includeMedia"> <widget class="QCheckBox" name="includeMedia">
<property name="text"> <property name="text">
@ -162,9 +172,14 @@
<tabstop>format</tabstop> <tabstop>format</tabstop>
<tabstop>deck</tabstop> <tabstop>deck</tabstop>
<tabstop>includeSched</tabstop> <tabstop>includeSched</tabstop>
<tabstop>include_deck_configs</tabstop>
<tabstop>includeMedia</tabstop> <tabstop>includeMedia</tabstop>
<tabstop>includeHTML</tabstop>
<tabstop>includeTags</tabstop> <tabstop>includeTags</tabstop>
<tabstop>buttonBox</tabstop> <tabstop>includeDeck</tabstop>
<tabstop>includeNotetype</tabstop>
<tabstop>includeGuid</tabstop>
<tabstop>legacy_support</tabstop>
</tabstops> </tabstops>
<resources/> <resources/>
<connections> <connections>

View file

@ -12,7 +12,13 @@ from typing import Optional, Sequence, Type
import aqt.forms import aqt.forms
import aqt.main import aqt.main
from anki.collection import DeckIdLimit, ExportLimit, NoteIdsLimit, Progress from anki.collection import (
DeckIdLimit,
ExportAnkiPackageOptions,
ExportLimit,
NoteIdsLimit,
Progress,
)
from anki.decks import DeckId, DeckNameId from anki.decks import DeckId, DeckNameId
from anki.notes import NoteId from anki.notes import NoteId
from aqt import gui_hooks from aqt import gui_hooks
@ -90,6 +96,9 @@ class ExportDialog(QDialog):
def exporter_changed(self, idx: int) -> None: def exporter_changed(self, idx: int) -> None:
self.exporter = self.exporter_classes[idx]() self.exporter = self.exporter_classes[idx]()
self.frm.includeSched.setVisible(self.exporter.show_include_scheduling) self.frm.includeSched.setVisible(self.exporter.show_include_scheduling)
self.frm.include_deck_configs.setVisible(
self.exporter.show_include_deck_configs
)
self.frm.includeMedia.setVisible(self.exporter.show_include_media) self.frm.includeMedia.setVisible(self.exporter.show_include_media)
self.frm.includeTags.setVisible(self.exporter.show_include_tags) self.frm.includeTags.setVisible(self.exporter.show_include_tags)
self.frm.includeHTML.setVisible(self.exporter.show_include_html) self.frm.includeHTML.setVisible(self.exporter.show_include_html)
@ -137,6 +146,7 @@ class ExportDialog(QDialog):
return ExportOptions( return ExportOptions(
out_path=out_path, out_path=out_path,
include_scheduling=self.frm.includeSched.isChecked(), include_scheduling=self.frm.includeSched.isChecked(),
include_deck_configs=self.frm.include_deck_configs.isChecked(),
include_media=self.frm.includeMedia.isChecked(), include_media=self.frm.includeMedia.isChecked(),
include_tags=self.frm.includeTags.isChecked(), include_tags=self.frm.includeTags.isChecked(),
include_html=self.frm.includeHTML.isChecked(), include_html=self.frm.includeHTML.isChecked(),
@ -170,6 +180,7 @@ class ExportDialog(QDialog):
class ExportOptions: class ExportOptions:
out_path: str out_path: str
include_scheduling: bool include_scheduling: bool
include_deck_configs: bool
include_media: bool include_media: bool
include_tags: bool include_tags: bool
include_html: bool include_html: bool
@ -184,6 +195,7 @@ class Exporter(ABC):
extension: str extension: str
show_deck_list = False show_deck_list = False
show_include_scheduling = False show_include_scheduling = False
show_include_deck_configs = False
show_include_media = False show_include_media = False
show_include_tags = False show_include_tags = False
show_include_html = False show_include_html = False
@ -241,6 +253,7 @@ class ApkgExporter(Exporter):
extension = "apkg" extension = "apkg"
show_deck_list = True show_deck_list = True
show_include_scheduling = True show_include_scheduling = True
show_include_deck_configs = True
show_include_media = True show_include_media = True
show_legacy_support = True show_legacy_support = True
@ -260,9 +273,12 @@ class ApkgExporter(Exporter):
op=lambda col: col.export_anki_package( op=lambda col: col.export_anki_package(
out_path=options.out_path, out_path=options.out_path,
limit=options.limit, limit=options.limit,
options=ExportAnkiPackageOptions(
with_scheduling=options.include_scheduling, with_scheduling=options.include_scheduling,
with_deck_configs=options.include_deck_configs,
with_media=options.include_media, with_media=options.include_media,
legacy_support=options.legacy_support, legacy=options.legacy_support,
),
), ),
success=on_success, success=on_success,
).with_backend_progress(export_progress_update).run_in_background() ).with_backend_progress(export_progress_update).run_in_background()

View file

@ -36,6 +36,7 @@ pub enum BoolKey {
ShiftPositionOfExistingCards, ShiftPositionOfExistingCards,
MergeNotetypes, MergeNotetypes,
WithScheduling, WithScheduling,
WithDeckConfigs,
Fsrs, Fsrs,
#[strum(to_string = "normalize_note_text")] #[strum(to_string = "normalize_note_text")]
NormalizeNoteText, NormalizeNoteText,

View file

@ -9,6 +9,7 @@ use itertools::Itertools;
use super::ExportProgress; use super::ExportProgress;
use crate::decks::immediate_parent_name; use crate::decks::immediate_parent_name;
use crate::decks::NormalDeck;
use crate::latex::extract_latex; use crate::latex::extract_latex;
use crate::prelude::*; use crate::prelude::*;
use crate::progress::ThrottlingProgressHandler; use crate::progress::ThrottlingProgressHandler;
@ -36,6 +37,7 @@ impl ExchangeData {
col: &mut Collection, col: &mut Collection,
search: impl TryIntoSearch, search: impl TryIntoSearch,
with_scheduling: bool, with_scheduling: bool,
with_deck_configs: bool,
) -> Result<()> { ) -> Result<()> {
self.days_elapsed = col.timing_today()?.days_elapsed; self.days_elapsed = col.timing_today()?.days_elapsed;
self.creation_utc_offset = col.get_creation_utc_offset(); self.creation_utc_offset = col.get_creation_utc_offset();
@ -43,18 +45,26 @@ impl ExchangeData {
self.notes = notes; self.notes = notes;
let (cards, guard) = guard.col.gather_cards()?; let (cards, guard) = guard.col.gather_cards()?;
self.cards = cards; self.cards = cards;
self.decks = guard.col.gather_decks(with_scheduling)?; self.decks = guard.col.gather_decks(with_scheduling, !with_scheduling)?;
self.notetypes = guard.col.gather_notetypes()?; self.notetypes = guard.col.gather_notetypes()?;
self.check_ids()?;
let allow_filtered = self.enables_filtered_decks();
if with_scheduling { if with_scheduling {
self.revlog = guard.col.gather_revlog()?; self.revlog = guard.col.gather_revlog()?;
self.deck_configs = guard.col.gather_deck_configs(&self.decks)?; if !allow_filtered {
self.restore_cards_from_filtered_decks();
}
} else { } else {
self.remove_scheduling_information(guard.col); self.reset_cards_and_notes(guard.col);
}; };
Ok(()) if with_deck_configs {
self.deck_configs = guard.col.gather_deck_configs(&self.decks)?;
}
self.reset_decks(!with_deck_configs, !with_scheduling, allow_filtered);
self.check_ids()
} }
pub(super) fn gather_media_names( pub(super) fn gather_media_names(
@ -78,9 +88,8 @@ impl ExchangeData {
Ok(()) Ok(())
} }
fn remove_scheduling_information(&mut self, col: &Collection) { fn reset_cards_and_notes(&mut self, col: &Collection) {
self.remove_system_tags(); self.remove_system_tags();
self.reset_deck_config_ids_and_limits();
self.reset_cards(col); self.reset_cards(col);
} }
@ -94,26 +103,73 @@ impl ExchangeData {
} }
} }
fn reset_deck_config_ids_and_limits(&mut self) { fn reset_decks(
&mut self,
reset_config_ids: bool,
reset_study_info: bool,
allow_filtered: bool,
) {
for deck in self.decks.iter_mut() { for deck in self.decks.iter_mut() {
if let Ok(normal_mut) = deck.normal_mut() { if reset_study_info {
normal_mut.config_id = 1; deck.common = Default::default();
normal_mut.review_limit = None; }
normal_mut.review_limit_today = None; match &mut deck.kind {
normal_mut.new_limit = None; DeckKind::Normal(normal) => {
normal_mut.new_limit_today = None; if reset_config_ids {
} else { normal.config_id = 1;
// filtered decks are reset at import time for legacy reasons }
if reset_study_info {
normal.extend_new = 0;
normal.extend_review = 0;
normal.review_limit = None;
normal.review_limit_today = None;
normal.new_limit = None;
normal.new_limit_today = None;
} }
} }
DeckKind::Filtered(_) if reset_study_info || !allow_filtered => {
deck.kind = DeckKind::Normal(NormalDeck {
config_id: 1,
..Default::default()
})
}
DeckKind::Filtered(_) => (),
}
}
}
/// Because the legacy exporter relied on the importer handling filtered
/// decks by converting them into regular ones, there are two scenarios to
/// watch out for:
/// 1. If exported without scheduling, cards have been reset, but their deck
/// ids may point to filtered decks.
/// 2. If exported with scheduling, cards have not been reset, but their
/// original deck ids may point to missing decks.
fn enables_filtered_decks(&self) -> bool {
self.cards
.iter()
.all(|c| self.card_and_its_deck_are_normal(c) || self.original_deck_exists(c))
}
fn card_and_its_deck_are_normal(&self, card: &Card) -> bool {
card.original_deck_id.0 == 0
&& self
.decks
.iter()
.find(|d| d.id == card.deck_id)
.map(|d| !d.is_filtered())
.unwrap_or_default()
}
fn original_deck_exists(&self, card: &Card) -> bool {
card.original_deck_id.0 == 1 || self.decks.iter().any(|d| d.id == card.original_deck_id)
} }
fn reset_cards(&mut self, col: &Collection) { fn reset_cards(&mut self, col: &Collection) {
let mut position = col.get_next_card_position(); let mut position = col.get_next_card_position();
for card in self.cards.iter_mut() { for card in self.cards.iter_mut() {
// schedule_as_new() removes cards from filtered decks, but we want to // schedule_as_new() removes cards from filtered decks, but we want to
// leave cards in their current deck, which gets converted to a regular // leave cards in their current deck, which gets converted to a regular one
// deck on import
let deck_id = card.deck_id; let deck_id = card.deck_id;
if card.schedule_as_new(position, true, true) { if card.schedule_as_new(position, true, true) {
position += 1; position += 1;
@ -123,6 +179,16 @@ impl ExchangeData {
} }
} }
fn restore_cards_from_filtered_decks(&mut self) {
for card in self.cards.iter_mut() {
if card.is_filtered() {
// instead of moving between decks, the deck is converted to a regular one
card.original_deck_id = card.deck_id;
card.remove_from_filtered_deck_restoring_queue();
}
}
}
fn check_ids(&self) -> Result<()> { fn check_ids(&self) -> Result<()> {
let tomorrow = TimestampMillis::now().adding_secs(86_400).0; let tomorrow = TimestampMillis::now().adding_secs(86_400).0;
if self if self
@ -183,12 +249,12 @@ impl Collection {
.map(|cards| (cards, guard)) .map(|cards| (cards, guard))
} }
/// If with_scheduling, also gather all original decks of cards in filtered /// If with_original, also gather all original decks of cards in filtered
/// decks, so they don't have to be converted to regular decks on import. /// decks, so they don't have to be converted to regular decks on import.
/// If not with_scheduling, skip exporting the default deck to avoid /// If skip_default, skip exporting the default deck to avoid
/// changing the importing client's defaults. /// changing the importing client's defaults.
fn gather_decks(&mut self, with_scheduling: bool) -> Result<Vec<Deck>> { fn gather_decks(&mut self, with_original: bool, skip_default: bool) -> Result<Vec<Deck>> {
let decks = if with_scheduling { let decks = if with_original {
self.storage.get_decks_and_original_for_search_cards() self.storage.get_decks_and_original_for_search_cards()
} else { } else {
self.storage.get_decks_for_search_cards() self.storage.get_decks_for_search_cards()
@ -197,7 +263,7 @@ impl Collection {
Ok(decks Ok(decks
.into_iter() .into_iter()
.chain(parents) .chain(parents)
.filter(|deck| with_scheduling || deck.id != DeckId(1)) .filter(|deck| !(skip_default && deck.id.0 == 1))
.collect()) .collect())
} }
@ -263,7 +329,7 @@ mod test {
let mut col = Collection::new(); let mut col = Collection::new();
let note = NoteAdder::basic(&mut col).add(&mut col); let note = NoteAdder::basic(&mut col).add(&mut col);
data.gather_data(&mut col, SearchNode::WholeCollection, true) data.gather_data(&mut col, SearchNode::WholeCollection, true, true)
.unwrap(); .unwrap();
assert_eq!(data.notes, [note]); assert_eq!(data.notes, [note]);
@ -280,7 +346,7 @@ mod test {
col.add_note_only_with_id_undoable(&mut note).unwrap(); col.add_note_only_with_id_undoable(&mut note).unwrap();
assert!(data assert!(data
.gather_data(&mut col, SearchNode::WholeCollection, true) .gather_data(&mut col, SearchNode::WholeCollection, true, true)
.is_err()); .is_err());
} }
} }

View file

@ -14,6 +14,7 @@ use crate::collection::CollectionBuilder;
use crate::import_export::gather::ExchangeData; use crate::import_export::gather::ExchangeData;
use crate::import_export::package::colpkg::export::export_collection; use crate::import_export::package::colpkg::export::export_collection;
use crate::import_export::package::media::MediaIter; use crate::import_export::package::media::MediaIter;
use crate::import_export::package::ExportAnkiPackageOptions;
use crate::import_export::package::Meta; use crate::import_export::package::Meta;
use crate::import_export::ExportProgress; use crate::import_export::ExportProgress;
use crate::prelude::*; use crate::prelude::*;
@ -21,14 +22,11 @@ use crate::progress::ThrottlingProgressHandler;
impl Collection { impl Collection {
/// Returns number of exported notes. /// Returns number of exported notes.
#[allow(clippy::too_many_arguments)]
pub fn export_apkg( pub fn export_apkg(
&mut self, &mut self,
out_path: impl AsRef<Path>, out_path: impl AsRef<Path>,
options: ExportAnkiPackageOptions,
search: impl TryIntoSearch, search: impl TryIntoSearch,
with_scheduling: bool,
with_media: bool,
legacy: bool,
media_fn: Option<Box<dyn FnOnce(HashSet<String>) -> MediaIter>>, media_fn: Option<Box<dyn FnOnce(HashSet<String>) -> MediaIter>>,
) -> Result<usize> { ) -> Result<usize> {
let mut progress = self.new_progress_handler(); let mut progress = self.new_progress_handler();
@ -38,19 +36,13 @@ impl Collection {
.path() .path()
.to_str() .to_str()
.or_invalid("non-unicode filename")?; .or_invalid("non-unicode filename")?;
let meta = if legacy { let meta = if options.legacy {
Meta::new_legacy() Meta::new_legacy()
} else { } else {
Meta::new() Meta::new()
}; };
let data = self.export_into_collection_file( let data =
&meta, self.export_into_collection_file(&meta, temp_col_path, options, search, &mut progress)?;
temp_col_path,
search,
&mut progress,
with_scheduling,
with_media,
)?;
progress.set(ExportProgress::File)?; progress.set(ExportProgress::File)?;
let media = if let Some(media_fn) = media_fn { let media = if let Some(media_fn) = media_fn {
@ -77,15 +69,19 @@ impl Collection {
&mut self, &mut self,
meta: &Meta, meta: &Meta,
path: &str, path: &str,
options: ExportAnkiPackageOptions,
search: impl TryIntoSearch, search: impl TryIntoSearch,
progress: &mut ThrottlingProgressHandler<ExportProgress>, progress: &mut ThrottlingProgressHandler<ExportProgress>,
with_scheduling: bool,
with_media: bool,
) -> Result<ExchangeData> { ) -> Result<ExchangeData> {
let mut data = ExchangeData::default(); let mut data = ExchangeData::default();
progress.set(ExportProgress::Gathering)?; progress.set(ExportProgress::Gathering)?;
data.gather_data(self, search, with_scheduling)?; data.gather_data(
if with_media { self,
search,
options.with_scheduling,
options.with_deck_configs,
)?;
if options.with_media {
data.gather_media_names(progress)?; data.gather_media_names(progress)?;
} }

View file

@ -78,7 +78,6 @@ impl Context<'_> {
notetype_map: &HashMap<NoteId, NotetypeId>, notetype_map: &HashMap<NoteId, NotetypeId>,
remapped_templates: &HashMap<NotetypeId, TemplateMap>, remapped_templates: &HashMap<NotetypeId, TemplateMap>,
imported_decks: &HashMap<DeckId, DeckId>, imported_decks: &HashMap<DeckId, DeckId>,
keep_filtered: bool,
) -> Result<()> { ) -> Result<()> {
let mut ctx = CardContext::new( let mut ctx = CardContext::new(
self.usn, self.usn,
@ -92,16 +91,16 @@ impl Context<'_> {
if ctx.scheduler_version == SchedulerVersion::V1 { if ctx.scheduler_version == SchedulerVersion::V1 {
return Err(AnkiError::SchedulerUpgradeRequired); return Err(AnkiError::SchedulerUpgradeRequired);
} }
ctx.import_cards(mem::take(&mut self.data.cards), keep_filtered)?; ctx.import_cards(mem::take(&mut self.data.cards))?;
ctx.import_revlog(mem::take(&mut self.data.revlog)) ctx.import_revlog(mem::take(&mut self.data.revlog))
} }
} }
impl CardContext<'_> { impl CardContext<'_> {
fn import_cards(&mut self, mut cards: Vec<Card>, keep_filtered: bool) -> Result<()> { fn import_cards(&mut self, mut cards: Vec<Card>) -> Result<()> {
for card in &mut cards { for card in &mut cards {
if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) { if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) {
self.add_card(card, keep_filtered)?; self.add_card(card)?;
} }
// TODO: could update existing card // TODO: could update existing card
} }
@ -133,14 +132,11 @@ impl CardContext<'_> {
.contains(&(card.note_id, card.template_idx)) .contains(&(card.note_id, card.template_idx))
} }
fn add_card(&mut self, card: &mut Card, keep_filtered: bool) -> Result<()> { fn add_card(&mut self, card: &mut Card) -> Result<()> {
card.usn = self.usn; card.usn = self.usn;
self.remap_deck_ids(card); self.remap_deck_ids(card);
self.remap_template_index(card); self.remap_template_index(card);
card.shift_collection_relative_dates(self.collection_delta); card.shift_collection_relative_dates(self.collection_delta);
if !keep_filtered {
card.maybe_remove_from_filtered_deck();
}
let old_id = self.uniquify_card_id(card); let old_id = self.uniquify_card_id(card);
self.target_col.add_card_if_unique_undoable(card)?; self.target_col.add_card_if_unique_undoable(card)?;
@ -198,12 +194,4 @@ impl Card {
fn original_due_in_days_since_collection_creation(&self) -> bool { fn original_due_in_days_since_collection_creation(&self) -> bool {
self.ctype == CardType::Review self.ctype == CardType::Review
} }
fn maybe_remove_from_filtered_deck(&mut self) {
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();
}
}
} }

View file

@ -29,18 +29,10 @@ impl<'d> DeckContext<'d> {
} }
impl Context<'_> { impl Context<'_> {
pub(super) fn import_decks_and_configs( pub(super) fn import_decks_and_configs(&mut self) -> Result<HashMap<DeckId, DeckId>> {
&mut self,
keep_filtered: bool,
contains_scheduling: bool,
) -> Result<HashMap<DeckId, DeckId>> {
let mut ctx = DeckContext::new(self.target_col, self.usn); let mut ctx = DeckContext::new(self.target_col, self.usn);
ctx.import_deck_configs(mem::take(&mut self.data.deck_configs))?; ctx.import_deck_configs(mem::take(&mut self.data.deck_configs))?;
ctx.import_decks( ctx.import_decks(mem::take(&mut self.data.decks))?;
mem::take(&mut self.data.decks),
keep_filtered,
contains_scheduling,
)?;
Ok(ctx.imported_decks) Ok(ctx.imported_decks)
} }
} }
@ -54,42 +46,16 @@ impl DeckContext<'_> {
Ok(()) Ok(())
} }
fn import_decks( fn import_decks(&mut self, mut decks: Vec<Deck>) -> Result<()> {
&mut self,
mut decks: Vec<Deck>,
keep_filtered: bool,
contains_scheduling: bool,
) -> Result<()> {
// ensure parents are seen before children // ensure parents are seen before children
decks.sort_unstable_by_key(|deck| deck.level()); decks.sort_unstable_by_key(|deck| deck.level());
for deck in &mut decks { for deck in &mut decks {
self.prepare_deck(deck, keep_filtered, contains_scheduling); self.maybe_reparent(deck);
self.import_deck(deck)?; self.import_deck(deck)?;
} }
Ok(()) Ok(())
} }
fn prepare_deck(&self, deck: &mut Deck, keep_filtered: bool, contains_scheduling: bool) {
self.maybe_reparent(deck);
if !keep_filtered && deck.is_filtered() {
deck.kind = DeckKind::Normal(NormalDeck {
config_id: 1,
..Default::default()
});
} else if !contains_scheduling {
// reset things like today's study count and collapse state
deck.common = Default::default();
deck.kind = match &mut deck.kind {
DeckKind::Normal(normal) => DeckKind::Normal(NormalDeck {
config_id: 1,
description: mem::take(&mut normal.description),
..Default::default()
}),
DeckKind::Filtered(_) => unreachable!(),
}
}
}
fn import_deck(&mut self, deck: &mut Deck) -> Result<()> { fn import_deck(&mut self, deck: &mut Deck) -> Result<()> {
if let Some(original) = self.get_deck_by_name(deck)? { if let Some(original) = self.get_deck_by_name(deck)? {
if original.is_same_kind(deck) { if original.is_same_kind(deck) {
@ -225,7 +191,7 @@ mod test {
DeckAdder::new("NEW PARENT::child").deck(), DeckAdder::new("NEW PARENT::child").deck(),
DeckAdder::new("new parent").deck(), DeckAdder::new("new parent").deck(),
]; ];
ctx.import_decks(imports, false, false).unwrap(); ctx.import_decks(imports).unwrap();
let existing_decks: HashSet<_> = ctx let existing_decks: HashSet<_> = ctx
.target_col .target_col
.get_all_deck_names(true) .get_all_deck_names(true)

View file

@ -6,7 +6,6 @@ mod decks;
mod media; mod media;
mod notes; mod notes;
use std::collections::HashSet;
use std::fs::File; use std::fs::File;
use std::path::Path; use std::path::Path;
@ -62,6 +61,7 @@ impl Collection {
self.transact(Op::Import, |col| { self.transact(Op::Import, |col| {
col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?; col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?;
col.set_config(BoolKey::WithScheduling, &options.with_scheduling)?; col.set_config(BoolKey::WithScheduling, &options.with_scheduling)?;
col.set_config(BoolKey::WithDeckConfigs, &options.with_deck_configs)?;
col.set_config(ConfigKey::UpdateNotes, &options.update_notes())?; col.set_config(ConfigKey::UpdateNotes, &options.update_notes())?;
col.set_config(ConfigKey::UpdateNotetypes, &options.update_notetypes())?; col.set_config(ConfigKey::UpdateNotetypes, &options.update_notetypes())?;
let mut ctx = Context::new(archive, col, options, progress)?; let mut ctx = Context::new(archive, col, options, progress)?;
@ -85,6 +85,7 @@ impl<'a> Context<'a> {
SearchNode::WholeCollection, SearchNode::WholeCollection,
&mut progress, &mut progress,
options.with_scheduling, options.with_scheduling,
options.with_deck_configs,
)?; )?;
let usn = target_col.usn()?; let usn = target_col.usn()?;
Ok(Self { Ok(Self {
@ -110,15 +111,12 @@ impl<'a> Context<'a> {
.collect(); .collect();
let mut media_map = self.prepare_media()?; let mut media_map = self.prepare_media()?;
let note_imports = self.import_notes_and_notetypes(&mut media_map)?; let note_imports = self.import_notes_and_notetypes(&mut media_map)?;
let keep_filtered = self.data.enables_filtered_decks(); let imported_decks = self.import_decks_and_configs()?;
let contains_scheduling = self.data.contains_scheduling();
let imported_decks = self.import_decks_and_configs(keep_filtered, contains_scheduling)?;
self.import_cards_and_revlog( self.import_cards_and_revlog(
&note_imports.id_map, &note_imports.id_map,
&notetypes, &notetypes,
&note_imports.remapped_templates, &note_imports.remapped_templates,
&imported_decks, &imported_decks,
keep_filtered,
)?; )?;
self.copy_media(&mut media_map)?; self.copy_media(&mut media_map)?;
Ok(note_imports.log) Ok(note_imports.log)
@ -132,6 +130,7 @@ impl ExchangeData {
search: impl TryIntoSearch, search: impl TryIntoSearch,
progress: &mut ThrottlingProgressHandler<ImportProgress>, progress: &mut ThrottlingProgressHandler<ImportProgress>,
with_scheduling: bool, with_scheduling: bool,
with_deck_configs: bool,
) -> Result<Self> { ) -> Result<Self> {
let tempfile = collection_to_tempfile(meta, archive)?; let tempfile = collection_to_tempfile(meta, archive)?;
let mut col = CollectionBuilder::new(tempfile.path()).build()?; let mut col = CollectionBuilder::new(tempfile.path()).build()?;
@ -140,31 +139,10 @@ impl ExchangeData {
progress.set(ImportProgress::Gathering)?; progress.set(ImportProgress::Gathering)?;
let mut data = ExchangeData::default(); let mut data = ExchangeData::default();
data.gather_data(&mut col, search, with_scheduling)?; data.gather_data(&mut col, search, with_scheduling, with_deck_configs)?;
Ok(data) Ok(data)
} }
fn enables_filtered_decks(&self) -> bool {
// Earlier versions relied on the importer handling filtered decks by converting
// them into regular ones, so there is no guarantee that all original decks
// are included. And the legacy exporter included the default deck config, so we
// can't use it to determine if scheduling is included.
self.contains_scheduling()
&& self.contains_all_original_decks()
&& !self.deck_configs.is_empty()
}
fn contains_scheduling(&self) -> bool {
!self.revlog.is_empty()
}
fn contains_all_original_decks(&self) -> bool {
let deck_ids: HashSet<_> = self.decks.iter().map(|d| d.id).collect();
self.cards
.iter()
.all(|c| c.original_deck_id.0 == 0 || deck_ids.contains(&c.original_deck_id))
}
} }
fn collection_to_tempfile(meta: &Meta, archive: &mut ZipArchive<File>) -> Result<NamedTempFile> { fn collection_to_tempfile(meta: &Meta, archive: &mut ZipArchive<File>) -> Result<NamedTempFile> {

View file

@ -602,6 +602,7 @@ impl Notetype {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use anki_proto::import_export::ExportAnkiPackageOptions;
use anki_proto::import_export::ImportAnkiPackageOptions; use anki_proto::import_export::ImportAnkiPackageOptions;
use tempfile::TempDir; use tempfile::TempDir;
@ -961,7 +962,7 @@ mod test {
.add(&mut src); .add(&mut src);
let temp_dir = TempDir::new()?; let temp_dir = TempDir::new()?;
let path = temp_dir.path().join("foo.apkg"); let path = temp_dir.path().join("foo.apkg");
src.export_apkg(&path, "", false, false, false, None)?; src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?;
let mut dst = CollectionBuilder::new(temp_dir.path().join("dst.anki2")) let mut dst = CollectionBuilder::new(temp_dir.path().join("dst.anki2"))
.with_desktop_media_paths() .with_desktop_media_paths()
@ -980,7 +981,7 @@ mod test {
// importing again with merge disabled will fail for the exisitng note, // importing again with merge disabled will fail for the exisitng note,
// but the new one will be added with an extra notetype // but the new one will be added with an extra notetype
assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7); assert_eq!(dst.storage.get_all_notetype_names().unwrap().len(), 7);
src.export_apkg(&path, "", false, false, false, None)?; src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?;
assert_eq!( assert_eq!(
dst.import_apkg(&path, ImportAnkiPackageOptions::default())? dst.import_apkg(&path, ImportAnkiPackageOptions::default())?
.output .output
@ -992,7 +993,7 @@ mod test {
// if enabling merge, it should succeed and remove the empty notetype, remapping // if enabling merge, it should succeed and remove the empty notetype, remapping
// its note // its note
src.export_apkg(&path, "", false, false, false, None)?; src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?;
assert_eq!( assert_eq!(
dst.import_apkg( dst.import_apkg(
&path, &path,

View file

@ -10,6 +10,7 @@ use std::io::Write;
use anki_io::read_file; use anki_io::read_file;
use anki_proto::import_export::ImportAnkiPackageOptions; use anki_proto::import_export::ImportAnkiPackageOptions;
use crate::import_export::package::ExportAnkiPackageOptions;
use crate::media::files::sha1_of_data; use crate::media::files::sha1_of_data;
use crate::media::MediaManager; use crate::media::MediaManager;
use crate::prelude::*; use crate::prelude::*;
@ -44,10 +45,13 @@ fn roundtrip_inner(legacy: bool) {
src_col src_col
.export_apkg( .export_apkg(
&apkg_path, &apkg_path,
SearchNode::from_deck_name("parent::sample"), ExportAnkiPackageOptions {
true, with_scheduling: true,
true, with_deck_configs: true,
with_media: true,
legacy, legacy,
},
SearchNode::from_deck_name("parent::sample"),
None, None,
) )
.unwrap(); .unwrap();

View file

@ -7,6 +7,7 @@ mod media;
mod meta; mod meta;
use anki_proto::import_export::media_entries::MediaEntry; use anki_proto::import_export::media_entries::MediaEntry;
pub use anki_proto::import_export::ExportAnkiPackageOptions;
pub use anki_proto::import_export::ImportAnkiPackageOptions; pub use anki_proto::import_export::ImportAnkiPackageOptions;
pub use anki_proto::import_export::ImportAnkiPackageUpdateCondition as UpdateCondition; pub use anki_proto::import_export::ImportAnkiPackageUpdateCondition as UpdateCondition;
use anki_proto::import_export::MediaEntries; use anki_proto::import_export::MediaEntries;

View file

@ -22,6 +22,7 @@ impl crate::services::ImportExportService for Collection {
Ok(anki_proto::import_export::ImportAnkiPackageOptions { Ok(anki_proto::import_export::ImportAnkiPackageOptions {
merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes), merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes),
with_scheduling: self.get_config_bool(BoolKey::WithScheduling), with_scheduling: self.get_config_bool(BoolKey::WithScheduling),
with_deck_configs: self.get_config_bool(BoolKey::WithDeckConfigs),
update_notes: self.get_update_notes() as i32, update_notes: self.get_update_notes() as i32,
update_notetypes: self.get_update_notetypes() as i32, update_notetypes: self.get_update_notetypes() as i32,
}) })
@ -33,10 +34,8 @@ impl crate::services::ImportExportService for Collection {
) -> Result<generic::UInt32> { ) -> Result<generic::UInt32> {
self.export_apkg( self.export_apkg(
&input.out_path, &input.out_path,
SearchNode::from(input.limit.unwrap_or_default()), input.options.unwrap_or_default(),
input.with_scheduling, input.limit.unwrap_or_default(),
input.with_media,
input.legacy,
None, None,
) )
.map(Into::into) .map(Into::into)

View file

@ -29,6 +29,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
help: tr.importingIncludeReviewsHelp(), help: tr.importingIncludeReviewsHelp(),
url: HelpPage.PackageImporting.scheduling, url: HelpPage.PackageImporting.scheduling,
}, },
withDeckConfigs: {
title: tr.importingWithDeckConfigs(),
help: tr.importingWithDeckConfigsHelp(),
url: HelpPage.PackageImporting.scheduling,
},
mergeNotetypes: { mergeNotetypes: {
title: tr.importingMergeNotetypes(), title: tr.importingMergeNotetypes(),
help: tr.importingMergeNotetypesHelp(), help: tr.importingMergeNotetypesHelp(),
@ -84,6 +89,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</SettingTitle> </SettingTitle>
</SwitchRow> </SwitchRow>
<SwitchRow bind:value={options.withDeckConfigs} defaultValue={false}>
<SettingTitle
on:click={() =>
openHelpModal(Object.keys(settings).indexOf("withDeckConfigs"))}
>
{settings.withDeckConfigs.title}
</SettingTitle>
</SwitchRow>
<details> <details>
<summary>{tr.importingUpdates()}</summary> <summary>{tr.importingUpdates()}</summary>
<SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}> <SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>