mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
d9e5c85686
commit
f200d6248e
18 changed files with 204 additions and 153 deletions
|
@ -18,7 +18,9 @@
|
|||
"env": {
|
||||
"PYTHONWARNINGS": "default",
|
||||
"PYTHONPYCACHEPREFIX": "out/pycache",
|
||||
"ANKIDEV": "1"
|
||||
"ANKIDEV": "1",
|
||||
"QTWEBENGINE_REMOTE_DEBUGGING": "8080",
|
||||
"QTWEBENGINE_CHROMIUM_FLAGS": "--remote-allow-origins=http://localhost:8080"
|
||||
},
|
||||
"justMyCode": true,
|
||||
"preLaunchTask": "ninja"
|
||||
|
|
|
@ -13,6 +13,7 @@ exporting-include = <b>Include</b>:
|
|||
exporting-include-html-and-media-references = Include HTML and media references
|
||||
exporting-include-media = Include media
|
||||
exporting-include-scheduling-information = Include scheduling information
|
||||
exporting-include-deck-configs = Include deck presets
|
||||
exporting-include-tags = Include tags
|
||||
exporting-support-older-anki-versions = Support older Anki versions (slower/larger files)
|
||||
exporting-notes-in-plain-text = Notes in Plain Text
|
||||
|
|
|
@ -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-updated-as-file-had-newer = Notes updated, as file had newer version: { $val }
|
||||
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-include-reviews-help =
|
||||
If enabled, any previous reviews that the deck sharer included will also be imported.
|
||||
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-pauker-18-lesson-paugz = Pauker 1.8 Lesson (*.pau.gz)
|
||||
# the '|' character
|
||||
|
|
|
@ -58,6 +58,7 @@ message ImportAnkiPackageOptions {
|
|||
ImportAnkiPackageUpdateCondition update_notes = 2;
|
||||
ImportAnkiPackageUpdateCondition update_notetypes = 3;
|
||||
bool with_scheduling = 4;
|
||||
bool with_deck_configs = 5;
|
||||
}
|
||||
|
||||
message ImportAnkiPackageRequest {
|
||||
|
@ -88,10 +89,15 @@ message ImportResponse {
|
|||
|
||||
message ExportAnkiPackageRequest {
|
||||
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 legacy = 4;
|
||||
ExportLimit limit = 5;
|
||||
}
|
||||
|
||||
message PackageMetadata {
|
||||
|
|
|
@ -42,6 +42,7 @@ BrowserColumns = search_pb2.BrowserColumns
|
|||
StripHtmlMode = card_rendering_pb2.StripHtmlRequest
|
||||
ImportLogWithChanges = import_export_pb2.ImportResponse
|
||||
ImportAnkiPackageRequest = import_export_pb2.ImportAnkiPackageRequest
|
||||
ExportAnkiPackageOptions = import_export_pb2.ExportAnkiPackageOptions
|
||||
ImportCsvRequest = import_export_pb2.ImportCsvRequest
|
||||
CsvMetadata = import_export_pb2.CsvMetadata
|
||||
DupeResolution = CsvMetadata.DupeResolution
|
||||
|
@ -361,19 +362,11 @@ class Collection(DeprecatedNamesMixin):
|
|||
return ImportLogWithChanges.FromString(log)
|
||||
|
||||
def export_anki_package(
|
||||
self,
|
||||
*,
|
||||
out_path: str,
|
||||
limit: ExportLimit,
|
||||
with_scheduling: bool,
|
||||
with_media: bool,
|
||||
legacy_support: bool,
|
||||
self, *, out_path: str, options: ExportAnkiPackageOptions, limit: ExportLimit
|
||||
) -> int:
|
||||
return self._backend.export_anki_package(
|
||||
out_path=out_path,
|
||||
with_scheduling=with_scheduling,
|
||||
with_media=with_media,
|
||||
legacy=legacy_support,
|
||||
options=options,
|
||||
limit=pb_export_limit(limit),
|
||||
)
|
||||
|
||||
|
|
|
@ -67,6 +67,16 @@
|
|||
</property>
|
||||
</widget>
|
||||
</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">
|
||||
<widget class="QCheckBox" name="includeMedia">
|
||||
<property name="text">
|
||||
|
@ -162,9 +172,14 @@
|
|||
<tabstop>format</tabstop>
|
||||
<tabstop>deck</tabstop>
|
||||
<tabstop>includeSched</tabstop>
|
||||
<tabstop>include_deck_configs</tabstop>
|
||||
<tabstop>includeMedia</tabstop>
|
||||
<tabstop>includeHTML</tabstop>
|
||||
<tabstop>includeTags</tabstop>
|
||||
<tabstop>buttonBox</tabstop>
|
||||
<tabstop>includeDeck</tabstop>
|
||||
<tabstop>includeNotetype</tabstop>
|
||||
<tabstop>includeGuid</tabstop>
|
||||
<tabstop>legacy_support</tabstop>
|
||||
</tabstops>
|
||||
<resources/>
|
||||
<connections>
|
||||
|
|
|
@ -12,7 +12,13 @@ from typing import Optional, Sequence, Type
|
|||
|
||||
import aqt.forms
|
||||
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.notes import NoteId
|
||||
from aqt import gui_hooks
|
||||
|
@ -90,6 +96,9 @@ class ExportDialog(QDialog):
|
|||
def exporter_changed(self, idx: int) -> None:
|
||||
self.exporter = self.exporter_classes[idx]()
|
||||
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.includeTags.setVisible(self.exporter.show_include_tags)
|
||||
self.frm.includeHTML.setVisible(self.exporter.show_include_html)
|
||||
|
@ -137,6 +146,7 @@ class ExportDialog(QDialog):
|
|||
return ExportOptions(
|
||||
out_path=out_path,
|
||||
include_scheduling=self.frm.includeSched.isChecked(),
|
||||
include_deck_configs=self.frm.include_deck_configs.isChecked(),
|
||||
include_media=self.frm.includeMedia.isChecked(),
|
||||
include_tags=self.frm.includeTags.isChecked(),
|
||||
include_html=self.frm.includeHTML.isChecked(),
|
||||
|
@ -170,6 +180,7 @@ class ExportDialog(QDialog):
|
|||
class ExportOptions:
|
||||
out_path: str
|
||||
include_scheduling: bool
|
||||
include_deck_configs: bool
|
||||
include_media: bool
|
||||
include_tags: bool
|
||||
include_html: bool
|
||||
|
@ -184,6 +195,7 @@ class Exporter(ABC):
|
|||
extension: str
|
||||
show_deck_list = False
|
||||
show_include_scheduling = False
|
||||
show_include_deck_configs = False
|
||||
show_include_media = False
|
||||
show_include_tags = False
|
||||
show_include_html = False
|
||||
|
@ -241,6 +253,7 @@ class ApkgExporter(Exporter):
|
|||
extension = "apkg"
|
||||
show_deck_list = True
|
||||
show_include_scheduling = True
|
||||
show_include_deck_configs = True
|
||||
show_include_media = True
|
||||
show_legacy_support = True
|
||||
|
||||
|
@ -260,9 +273,12 @@ class ApkgExporter(Exporter):
|
|||
op=lambda col: col.export_anki_package(
|
||||
out_path=options.out_path,
|
||||
limit=options.limit,
|
||||
with_scheduling=options.include_scheduling,
|
||||
with_media=options.include_media,
|
||||
legacy_support=options.legacy_support,
|
||||
options=ExportAnkiPackageOptions(
|
||||
with_scheduling=options.include_scheduling,
|
||||
with_deck_configs=options.include_deck_configs,
|
||||
with_media=options.include_media,
|
||||
legacy=options.legacy_support,
|
||||
),
|
||||
),
|
||||
success=on_success,
|
||||
).with_backend_progress(export_progress_update).run_in_background()
|
||||
|
|
|
@ -36,6 +36,7 @@ pub enum BoolKey {
|
|||
ShiftPositionOfExistingCards,
|
||||
MergeNotetypes,
|
||||
WithScheduling,
|
||||
WithDeckConfigs,
|
||||
Fsrs,
|
||||
#[strum(to_string = "normalize_note_text")]
|
||||
NormalizeNoteText,
|
||||
|
|
|
@ -9,6 +9,7 @@ use itertools::Itertools;
|
|||
|
||||
use super::ExportProgress;
|
||||
use crate::decks::immediate_parent_name;
|
||||
use crate::decks::NormalDeck;
|
||||
use crate::latex::extract_latex;
|
||||
use crate::prelude::*;
|
||||
use crate::progress::ThrottlingProgressHandler;
|
||||
|
@ -36,6 +37,7 @@ impl ExchangeData {
|
|||
col: &mut Collection,
|
||||
search: impl TryIntoSearch,
|
||||
with_scheduling: bool,
|
||||
with_deck_configs: bool,
|
||||
) -> Result<()> {
|
||||
self.days_elapsed = col.timing_today()?.days_elapsed;
|
||||
self.creation_utc_offset = col.get_creation_utc_offset();
|
||||
|
@ -43,18 +45,26 @@ impl ExchangeData {
|
|||
self.notes = notes;
|
||||
let (cards, guard) = guard.col.gather_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.check_ids()?;
|
||||
|
||||
let allow_filtered = self.enables_filtered_decks();
|
||||
|
||||
if with_scheduling {
|
||||
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 {
|
||||
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(
|
||||
|
@ -78,9 +88,8 @@ impl ExchangeData {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_scheduling_information(&mut self, col: &Collection) {
|
||||
fn reset_cards_and_notes(&mut self, col: &Collection) {
|
||||
self.remove_system_tags();
|
||||
self.reset_deck_config_ids_and_limits();
|
||||
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() {
|
||||
if let Ok(normal_mut) = deck.normal_mut() {
|
||||
normal_mut.config_id = 1;
|
||||
normal_mut.review_limit = None;
|
||||
normal_mut.review_limit_today = None;
|
||||
normal_mut.new_limit = None;
|
||||
normal_mut.new_limit_today = None;
|
||||
} else {
|
||||
// filtered decks are reset at import time for legacy reasons
|
||||
if reset_study_info {
|
||||
deck.common = Default::default();
|
||||
}
|
||||
match &mut deck.kind {
|
||||
DeckKind::Normal(normal) => {
|
||||
if reset_config_ids {
|
||||
normal.config_id = 1;
|
||||
}
|
||||
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) {
|
||||
let mut position = col.get_next_card_position();
|
||||
for card in self.cards.iter_mut() {
|
||||
// schedule_as_new() removes cards from filtered decks, but we want to
|
||||
// leave cards in their current deck, which gets converted to a regular
|
||||
// deck on import
|
||||
// leave cards in their current deck, which gets converted to a regular one
|
||||
let deck_id = card.deck_id;
|
||||
if card.schedule_as_new(position, true, true) {
|
||||
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<()> {
|
||||
let tomorrow = TimestampMillis::now().adding_secs(86_400).0;
|
||||
if self
|
||||
|
@ -183,12 +249,12 @@ impl Collection {
|
|||
.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.
|
||||
/// 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.
|
||||
fn gather_decks(&mut self, with_scheduling: bool) -> Result<Vec<Deck>> {
|
||||
let decks = if with_scheduling {
|
||||
fn gather_decks(&mut self, with_original: bool, skip_default: bool) -> Result<Vec<Deck>> {
|
||||
let decks = if with_original {
|
||||
self.storage.get_decks_and_original_for_search_cards()
|
||||
} else {
|
||||
self.storage.get_decks_for_search_cards()
|
||||
|
@ -197,7 +263,7 @@ impl Collection {
|
|||
Ok(decks
|
||||
.into_iter()
|
||||
.chain(parents)
|
||||
.filter(|deck| with_scheduling || deck.id != DeckId(1))
|
||||
.filter(|deck| !(skip_default && deck.id.0 == 1))
|
||||
.collect())
|
||||
}
|
||||
|
||||
|
@ -263,7 +329,7 @@ mod test {
|
|||
let mut col = Collection::new();
|
||||
|
||||
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();
|
||||
|
||||
assert_eq!(data.notes, [note]);
|
||||
|
@ -280,7 +346,7 @@ mod test {
|
|||
col.add_note_only_with_id_undoable(&mut note).unwrap();
|
||||
|
||||
assert!(data
|
||||
.gather_data(&mut col, SearchNode::WholeCollection, true)
|
||||
.gather_data(&mut col, SearchNode::WholeCollection, true, true)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ use crate::collection::CollectionBuilder;
|
|||
use crate::import_export::gather::ExchangeData;
|
||||
use crate::import_export::package::colpkg::export::export_collection;
|
||||
use crate::import_export::package::media::MediaIter;
|
||||
use crate::import_export::package::ExportAnkiPackageOptions;
|
||||
use crate::import_export::package::Meta;
|
||||
use crate::import_export::ExportProgress;
|
||||
use crate::prelude::*;
|
||||
|
@ -21,14 +22,11 @@ use crate::progress::ThrottlingProgressHandler;
|
|||
|
||||
impl Collection {
|
||||
/// Returns number of exported notes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_apkg(
|
||||
&mut self,
|
||||
out_path: impl AsRef<Path>,
|
||||
options: ExportAnkiPackageOptions,
|
||||
search: impl TryIntoSearch,
|
||||
with_scheduling: bool,
|
||||
with_media: bool,
|
||||
legacy: bool,
|
||||
media_fn: Option<Box<dyn FnOnce(HashSet<String>) -> MediaIter>>,
|
||||
) -> Result<usize> {
|
||||
let mut progress = self.new_progress_handler();
|
||||
|
@ -38,19 +36,13 @@ impl Collection {
|
|||
.path()
|
||||
.to_str()
|
||||
.or_invalid("non-unicode filename")?;
|
||||
let meta = if legacy {
|
||||
let meta = if options.legacy {
|
||||
Meta::new_legacy()
|
||||
} else {
|
||||
Meta::new()
|
||||
};
|
||||
let data = self.export_into_collection_file(
|
||||
&meta,
|
||||
temp_col_path,
|
||||
search,
|
||||
&mut progress,
|
||||
with_scheduling,
|
||||
with_media,
|
||||
)?;
|
||||
let data =
|
||||
self.export_into_collection_file(&meta, temp_col_path, options, search, &mut progress)?;
|
||||
|
||||
progress.set(ExportProgress::File)?;
|
||||
let media = if let Some(media_fn) = media_fn {
|
||||
|
@ -77,15 +69,19 @@ impl Collection {
|
|||
&mut self,
|
||||
meta: &Meta,
|
||||
path: &str,
|
||||
options: ExportAnkiPackageOptions,
|
||||
search: impl TryIntoSearch,
|
||||
progress: &mut ThrottlingProgressHandler<ExportProgress>,
|
||||
with_scheduling: bool,
|
||||
with_media: bool,
|
||||
) -> Result<ExchangeData> {
|
||||
let mut data = ExchangeData::default();
|
||||
progress.set(ExportProgress::Gathering)?;
|
||||
data.gather_data(self, search, with_scheduling)?;
|
||||
if with_media {
|
||||
data.gather_data(
|
||||
self,
|
||||
search,
|
||||
options.with_scheduling,
|
||||
options.with_deck_configs,
|
||||
)?;
|
||||
if options.with_media {
|
||||
data.gather_media_names(progress)?;
|
||||
}
|
||||
|
||||
|
|
|
@ -78,7 +78,6 @@ impl Context<'_> {
|
|||
notetype_map: &HashMap<NoteId, NotetypeId>,
|
||||
remapped_templates: &HashMap<NotetypeId, TemplateMap>,
|
||||
imported_decks: &HashMap<DeckId, DeckId>,
|
||||
keep_filtered: bool,
|
||||
) -> Result<()> {
|
||||
let mut ctx = CardContext::new(
|
||||
self.usn,
|
||||
|
@ -92,16 +91,16 @@ impl Context<'_> {
|
|||
if ctx.scheduler_version == SchedulerVersion::V1 {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
@ -133,14 +132,11 @@ impl CardContext<'_> {
|
|||
.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;
|
||||
self.remap_deck_ids(card);
|
||||
self.remap_template_index(card);
|
||||
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);
|
||||
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -29,18 +29,10 @@ impl<'d> DeckContext<'d> {
|
|||
}
|
||||
|
||||
impl Context<'_> {
|
||||
pub(super) fn import_decks_and_configs(
|
||||
&mut self,
|
||||
keep_filtered: bool,
|
||||
contains_scheduling: bool,
|
||||
) -> Result<HashMap<DeckId, DeckId>> {
|
||||
pub(super) fn import_decks_and_configs(&mut self) -> Result<HashMap<DeckId, DeckId>> {
|
||||
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),
|
||||
keep_filtered,
|
||||
contains_scheduling,
|
||||
)?;
|
||||
ctx.import_decks(mem::take(&mut self.data.decks))?;
|
||||
Ok(ctx.imported_decks)
|
||||
}
|
||||
}
|
||||
|
@ -54,42 +46,16 @@ impl DeckContext<'_> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn import_decks(
|
||||
&mut self,
|
||||
mut decks: Vec<Deck>,
|
||||
keep_filtered: bool,
|
||||
contains_scheduling: bool,
|
||||
) -> Result<()> {
|
||||
fn import_decks(&mut self, mut decks: Vec<Deck>) -> Result<()> {
|
||||
// ensure parents are seen before children
|
||||
decks.sort_unstable_by_key(|deck| deck.level());
|
||||
for deck in &mut decks {
|
||||
self.prepare_deck(deck, keep_filtered, contains_scheduling);
|
||||
self.maybe_reparent(deck);
|
||||
self.import_deck(deck)?;
|
||||
}
|
||||
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<()> {
|
||||
if let Some(original) = self.get_deck_by_name(deck)? {
|
||||
if original.is_same_kind(deck) {
|
||||
|
@ -225,7 +191,7 @@ mod test {
|
|||
DeckAdder::new("NEW PARENT::child").deck(),
|
||||
DeckAdder::new("new parent").deck(),
|
||||
];
|
||||
ctx.import_decks(imports, false, false).unwrap();
|
||||
ctx.import_decks(imports).unwrap();
|
||||
let existing_decks: HashSet<_> = ctx
|
||||
.target_col
|
||||
.get_all_deck_names(true)
|
||||
|
|
|
@ -6,7 +6,6 @@ mod decks;
|
|||
mod media;
|
||||
mod notes;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::path::Path;
|
||||
|
||||
|
@ -62,6 +61,7 @@ impl Collection {
|
|||
self.transact(Op::Import, |col| {
|
||||
col.set_config(BoolKey::MergeNotetypes, &options.merge_notetypes)?;
|
||||
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::UpdateNotetypes, &options.update_notetypes())?;
|
||||
let mut ctx = Context::new(archive, col, options, progress)?;
|
||||
|
@ -85,6 +85,7 @@ impl<'a> Context<'a> {
|
|||
SearchNode::WholeCollection,
|
||||
&mut progress,
|
||||
options.with_scheduling,
|
||||
options.with_deck_configs,
|
||||
)?;
|
||||
let usn = target_col.usn()?;
|
||||
Ok(Self {
|
||||
|
@ -110,15 +111,12 @@ impl<'a> Context<'a> {
|
|||
.collect();
|
||||
let mut media_map = self.prepare_media()?;
|
||||
let note_imports = self.import_notes_and_notetypes(&mut media_map)?;
|
||||
let keep_filtered = self.data.enables_filtered_decks();
|
||||
let contains_scheduling = self.data.contains_scheduling();
|
||||
let imported_decks = self.import_decks_and_configs(keep_filtered, contains_scheduling)?;
|
||||
let imported_decks = self.import_decks_and_configs()?;
|
||||
self.import_cards_and_revlog(
|
||||
¬e_imports.id_map,
|
||||
¬etypes,
|
||||
¬e_imports.remapped_templates,
|
||||
&imported_decks,
|
||||
keep_filtered,
|
||||
)?;
|
||||
self.copy_media(&mut media_map)?;
|
||||
Ok(note_imports.log)
|
||||
|
@ -132,6 +130,7 @@ impl ExchangeData {
|
|||
search: impl TryIntoSearch,
|
||||
progress: &mut ThrottlingProgressHandler<ImportProgress>,
|
||||
with_scheduling: bool,
|
||||
with_deck_configs: bool,
|
||||
) -> Result<Self> {
|
||||
let tempfile = collection_to_tempfile(meta, archive)?;
|
||||
let mut col = CollectionBuilder::new(tempfile.path()).build()?;
|
||||
|
@ -140,31 +139,10 @@ impl ExchangeData {
|
|||
|
||||
progress.set(ImportProgress::Gathering)?;
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
|
|
|
@ -602,6 +602,7 @@ impl Notetype {
|
|||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use anki_proto::import_export::ExportAnkiPackageOptions;
|
||||
use anki_proto::import_export::ImportAnkiPackageOptions;
|
||||
use tempfile::TempDir;
|
||||
|
||||
|
@ -961,7 +962,7 @@ mod test {
|
|||
.add(&mut src);
|
||||
let temp_dir = TempDir::new()?;
|
||||
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"))
|
||||
.with_desktop_media_paths()
|
||||
|
@ -980,7 +981,7 @@ mod test {
|
|||
// importing again with merge disabled will fail for the exisitng note,
|
||||
// but the new one will be added with an extra notetype
|
||||
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!(
|
||||
dst.import_apkg(&path, ImportAnkiPackageOptions::default())?
|
||||
.output
|
||||
|
@ -992,7 +993,7 @@ mod test {
|
|||
|
||||
// if enabling merge, it should succeed and remove the empty notetype, remapping
|
||||
// its note
|
||||
src.export_apkg(&path, "", false, false, false, None)?;
|
||||
src.export_apkg(&path, ExportAnkiPackageOptions::default(), "", None)?;
|
||||
assert_eq!(
|
||||
dst.import_apkg(
|
||||
&path,
|
||||
|
|
|
@ -10,6 +10,7 @@ use std::io::Write;
|
|||
use anki_io::read_file;
|
||||
use anki_proto::import_export::ImportAnkiPackageOptions;
|
||||
|
||||
use crate::import_export::package::ExportAnkiPackageOptions;
|
||||
use crate::media::files::sha1_of_data;
|
||||
use crate::media::MediaManager;
|
||||
use crate::prelude::*;
|
||||
|
@ -44,10 +45,13 @@ fn roundtrip_inner(legacy: bool) {
|
|||
src_col
|
||||
.export_apkg(
|
||||
&apkg_path,
|
||||
ExportAnkiPackageOptions {
|
||||
with_scheduling: true,
|
||||
with_deck_configs: true,
|
||||
with_media: true,
|
||||
legacy,
|
||||
},
|
||||
SearchNode::from_deck_name("parent::sample"),
|
||||
true,
|
||||
true,
|
||||
legacy,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
|
|
@ -7,6 +7,7 @@ mod media;
|
|||
mod meta;
|
||||
|
||||
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::ImportAnkiPackageUpdateCondition as UpdateCondition;
|
||||
use anki_proto::import_export::MediaEntries;
|
||||
|
|
|
@ -22,6 +22,7 @@ impl crate::services::ImportExportService for Collection {
|
|||
Ok(anki_proto::import_export::ImportAnkiPackageOptions {
|
||||
merge_notetypes: self.get_config_bool(BoolKey::MergeNotetypes),
|
||||
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_notetypes: self.get_update_notetypes() as i32,
|
||||
})
|
||||
|
@ -33,10 +34,8 @@ impl crate::services::ImportExportService for Collection {
|
|||
) -> Result<generic::UInt32> {
|
||||
self.export_apkg(
|
||||
&input.out_path,
|
||||
SearchNode::from(input.limit.unwrap_or_default()),
|
||||
input.with_scheduling,
|
||||
input.with_media,
|
||||
input.legacy,
|
||||
input.options.unwrap_or_default(),
|
||||
input.limit.unwrap_or_default(),
|
||||
None,
|
||||
)
|
||||
.map(Into::into)
|
||||
|
|
|
@ -29,6 +29,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
help: tr.importingIncludeReviewsHelp(),
|
||||
url: HelpPage.PackageImporting.scheduling,
|
||||
},
|
||||
withDeckConfigs: {
|
||||
title: tr.importingWithDeckConfigs(),
|
||||
help: tr.importingWithDeckConfigsHelp(),
|
||||
url: HelpPage.PackageImporting.scheduling,
|
||||
},
|
||||
mergeNotetypes: {
|
||||
title: tr.importingMergeNotetypes(),
|
||||
help: tr.importingMergeNotetypesHelp(),
|
||||
|
@ -84,6 +89,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
|
||||
<SwitchRow bind:value={options.withDeckConfigs} defaultValue={false}>
|
||||
<SettingTitle
|
||||
on:click={() =>
|
||||
openHelpModal(Object.keys(settings).indexOf("withDeckConfigs"))}
|
||||
>
|
||||
{settings.withDeckConfigs.title}
|
||||
</SettingTitle>
|
||||
</SwitchRow>
|
||||
|
||||
<details>
|
||||
<summary>{tr.importingUpdates()}</summary>
|
||||
<SwitchRow bind:value={options.mergeNotetypes} defaultValue={false}>
|
||||
|
|
Loading…
Reference in a new issue