diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl
index 76e3132a0..779dc4f70 100644
--- a/ftl/core/preferences.ftl
+++ b/ftl/core/preferences.ftl
@@ -46,3 +46,5 @@ preferences-daily-backups = Daily backups to keep:
preferences-weekly-backups = Weekly backups to keep:
preferences-monthly-backups = Monthly backups to keep:
preferences-minutes-between-backups = Minutes between automatic backups:
+preferences-reduce-motion = Reduce motion
+preferences-reduce-motion-tooltip = Disable various animations and transitions of the user interface
diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui
index eb4a4bd35..d1b7f4aac 100644
--- a/qt/aqt/forms/preferences.ui
+++ b/qt/aqt/forms/preferences.ui
@@ -7,7 +7,7 @@
0
0
640
- 530
+ 640
@@ -42,6 +42,9 @@
12
+ -
+
+
-
@@ -55,9 +58,6 @@
- -
-
-
-
@@ -103,6 +103,16 @@
+ -
+
+
+ preferences_reduce_motion_tooltip
+
+
+ preferences_reduce_motion
+
+
+
-
-
@@ -676,8 +686,8 @@
- lang
theme
+ lang
video_driver
showPlayButtons
interrupt_audio
@@ -685,6 +695,7 @@
paste_strips_formatting
ignore_accents_in_search
legacy_import_export
+ reduce_motion
useCurrent
default_search_text
uiScale
@@ -703,11 +714,11 @@
fullSync
syncDeauth
media_log
- tabWidget
minutes_between_backups
daily_backups
weekly_backups
monthly_backups
+ tabWidget
diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py
index f3b1fee0f..9f485764b 100644
--- a/qt/aqt/preferences.py
+++ b/qt/aqt/preferences.py
@@ -207,6 +207,7 @@ class Preferences(QDialog):
def setup_global(self) -> None:
"Setup options global to all profiles."
+ self.form.reduce_motion.setChecked(self.mw.pm.reduced_motion())
self.form.uiScale.setValue(int(self.mw.pm.uiScale() * 100))
themes = [
tr.preferences_theme_label(theme=theme)
@@ -236,6 +237,8 @@ class Preferences(QDialog):
self.mw.pm.setUiScale(newScale)
restart_required = True
+ self.mw.pm.set_reduced_motion(self.form.reduce_motion.isChecked())
+
self.mw.pm.set_legacy_import_export(self.form.legacy_import_export.isChecked())
if restart_required:
diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py
index f177950d5..75cc87cde 100644
--- a/qt/aqt/profiles.py
+++ b/qt/aqt/profiles.py
@@ -518,6 +518,12 @@ create table if not exists profiles
def setUiScale(self, scale: float) -> None:
self.meta["uiScale"] = scale
+ def reduced_motion(self) -> bool:
+ return self.meta.get("reduced_motion", False)
+
+ def set_reduced_motion(self, on: bool) -> None:
+ self.meta["reduced_motion"] = on
+
def last_addon_update_check(self) -> int:
return self.meta.get("last_addon_update_check", 0)
diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py
index ad19f5761..acab4a43c 100644
--- a/qt/aqt/theme.py
+++ b/qt/aqt/theme.py
@@ -122,7 +122,7 @@ class ThemeManager:
return cache.setdefault(path, icon)
def body_class(self, night_mode: bool | None = None) -> str:
- "Returns space-separated class list for platform/theme."
+ "Returns space-separated class list for platform/theme/global settings."
classes = []
if is_win:
classes.append("isWin")
@@ -137,6 +137,8 @@ class ThemeManager:
classes.extend(["nightMode", "night_mode"])
if self.macos_dark_mode():
classes.append("macos-dark-mode")
+ if aqt.mw.pm.reduced_motion():
+ classes.append("reduced-motion")
return " ".join(classes)
def body_classes_for_card_ord(
diff --git a/rslib/src/import_export/gather.rs b/rslib/src/import_export/gather.rs
index 7b7cb76e1..15025440f 100644
--- a/rslib/src/import_export/gather.rs
+++ b/rslib/src/import_export/gather.rs
@@ -42,7 +42,7 @@ impl ExchangeData {
self.notes = notes;
let (cards, guard) = guard.col.gather_cards()?;
self.cards = cards;
- self.decks = guard.col.gather_decks()?;
+ self.decks = guard.col.gather_decks(with_scheduling)?;
self.notetypes = guard.col.gather_notetypes()?;
self.check_ids()?;
@@ -191,13 +191,21 @@ impl Collection {
.map(|cards| (cards, guard))
}
- fn gather_decks(&mut self) -> Result> {
- let decks = self.storage.get_decks_for_search_cards()?;
+ /// If with_scheduling, 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 changing
+ /// the importing client's defaults.
+ fn gather_decks(&mut self, with_scheduling: bool) -> Result> {
+ let decks = if with_scheduling {
+ self.storage.get_decks_and_original_for_search_cards()
+ } else {
+ self.storage.get_decks_for_search_cards()
+ }?;
let parents = self.get_parent_decks(&decks)?;
Ok(decks
.into_iter()
- .filter(|deck| deck.id != DeckId(1))
.chain(parents)
+ .filter(|deck| with_scheduling || deck.id != DeckId(1))
.collect())
}
@@ -243,7 +251,6 @@ impl Collection {
.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)?
diff --git a/rslib/src/import_export/package/apkg/import/cards.rs b/rslib/src/import_export/package/apkg/import/cards.rs
index 6a87510c1..563037ce1 100644
--- a/rslib/src/import_export/package/apkg/import/cards.rs
+++ b/rslib/src/import_export/package/apkg/import/cards.rs
@@ -70,6 +70,7 @@ impl Context<'_> {
&mut self,
imported_notes: &HashMap,
imported_decks: &HashMap,
+ keep_filtered: bool,
) -> Result<()> {
let mut ctx = CardContext::new(
self.usn,
@@ -78,16 +79,16 @@ impl Context<'_> {
imported_notes,
imported_decks,
)?;
- ctx.import_cards(mem::take(&mut self.data.cards))?;
+ ctx.import_cards(mem::take(&mut self.data.cards), keep_filtered)?;
ctx.import_revlog(mem::take(&mut self.data.revlog))
}
}
impl CardContext<'_> {
- fn import_cards(&mut self, mut cards: Vec) -> Result<()> {
+ fn import_cards(&mut self, mut cards: Vec, keep_filtered: bool) -> Result<()> {
for card in &mut cards {
if self.map_to_imported_note(card) && !self.card_ordinal_already_exists(card) {
- self.add_card(card)?;
+ self.add_card(card, keep_filtered)?;
}
// TODO: could update existing card
}
@@ -119,11 +120,13 @@ impl CardContext<'_> {
.contains(&(card.note_id, card.template_idx))
}
- fn add_card(&mut self, card: &mut Card) -> Result<()> {
+ fn add_card(&mut self, card: &mut Card, keep_filtered: bool) -> Result<()> {
card.usn = self.usn;
- self.remap_deck_id(card);
+ self.remap_deck_ids(card);
card.shift_collection_relative_dates(self.collection_delta);
- card.maybe_remove_from_filtered_deck(self.scheduler_version);
+ if !keep_filtered {
+ 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)?;
@@ -141,10 +144,13 @@ impl CardContext<'_> {
original
}
- fn remap_deck_id(&self, card: &mut Card) {
+ fn remap_deck_ids(&self, card: &mut Card) {
if let Some(did) = self.remapped_decks.get(&card.deck_id) {
card.deck_id = *did;
}
+ if let Some(did) = self.remapped_decks.get(&card.original_deck_id) {
+ card.original_deck_id = *did;
+ }
}
}
diff --git a/rslib/src/import_export/package/apkg/import/decks.rs b/rslib/src/import_export/package/apkg/import/decks.rs
index 49c81a304..0b2aa2811 100644
--- a/rslib/src/import_export/package/apkg/import/decks.rs
+++ b/rslib/src/import_export/package/apkg/import/decks.rs
@@ -27,10 +27,13 @@ impl<'d> DeckContext<'d> {
}
impl Context<'_> {
- pub(super) fn import_decks_and_configs(&mut self) -> Result> {
+ pub(super) fn import_decks_and_configs(
+ &mut self,
+ keep_filtered: bool,
+ ) -> 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))?;
+ ctx.import_decks(mem::take(&mut self.data.decks), keep_filtered)?;
Ok(ctx.imported_decks)
}
}
@@ -44,19 +47,19 @@ impl DeckContext<'_> {
Ok(())
}
- fn import_decks(&mut self, mut decks: Vec) -> Result<()> {
+ fn import_decks(&mut self, mut decks: Vec, keep_filtered: bool) -> 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.prepare_deck(deck, keep_filtered);
self.import_deck(deck)?;
}
Ok(())
}
- fn prepare_deck(&mut self, deck: &mut Deck) {
+ fn prepare_deck(&self, deck: &mut Deck, keep_filtered: bool) {
self.maybe_reparent(deck);
- if deck.is_filtered() {
+ if !keep_filtered && deck.is_filtered() {
deck.kind = DeckKind::Normal(NormalDeck {
config_id: 1,
..Default::default()
@@ -66,16 +69,14 @@ impl DeckContext<'_> {
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)
+ if original.is_same_kind(deck) {
+ return self.update_deck(deck, original);
} else {
- self.update_deck(deck, original)
+ self.uniquify_name(deck);
}
- } else {
- self.ensure_valid_first_existing_parent(deck)?;
- self.add_deck(deck)
}
+ self.ensure_valid_first_existing_parent(deck)?;
+ self.add_deck(deck)
}
fn maybe_reparent(&self, deck: &mut Deck) {
@@ -113,10 +114,16 @@ impl DeckContext<'_> {
Ok(())
}
- /// Caller must ensure decks are normal.
+ /// Caller must ensure decks are of the same kind.
fn update_deck(&mut self, deck: &Deck, original: Deck) -> Result<()> {
let mut new_deck = original.clone();
- new_deck.normal_mut()?.update_with_other(deck.normal()?);
+ if let (Ok(new), Ok(old)) = (new_deck.normal_mut(), deck.normal()) {
+ new.update_with_other(old);
+ } else if let (Ok(new), Ok(old)) = (new_deck.filtered_mut(), deck.filtered()) {
+ *new = old.clone();
+ } else {
+ return Err(AnkiError::invalid_input("decks have different kinds"));
+ }
self.imported_decks.insert(deck.id, new_deck.id);
self.target_col
.update_deck_inner(&mut new_deck, original, self.usn)
@@ -152,6 +159,10 @@ impl Deck {
fn level(&self) -> usize {
self.name.components().count()
}
+
+ fn is_same_kind(&self, other: &Self) -> bool {
+ self.is_filtered() == other.is_filtered()
+ }
}
impl NormalDeck {
@@ -194,7 +205,7 @@ mod test {
new_deck_with_machine_name("NEW PARENT\x1fchild", false),
new_deck_with_machine_name("new parent", false),
];
- ctx.import_decks(imports).unwrap();
+ ctx.import_decks(imports, false).unwrap();
let existing_decks: HashSet<_> = ctx
.target_col
.get_all_deck_names(true)
diff --git a/rslib/src/import_export/package/apkg/import/mod.rs b/rslib/src/import_export/package/apkg/import/mod.rs
index e0cc2b2d8..7355acf88 100644
--- a/rslib/src/import_export/package/apkg/import/mod.rs
+++ b/rslib/src/import_export/package/apkg/import/mod.rs
@@ -6,7 +6,7 @@ mod decks;
mod media;
mod notes;
-use std::{fs::File, io, path::Path};
+use std::{collections::HashSet, fs::File, io, path::Path};
pub(crate) use notes::NoteMeta;
use rusqlite::OptionalExtension;
@@ -82,8 +82,9 @@ impl<'a> Context<'a> {
fn import(&mut self) -> Result {
let mut media_map = self.prepare_media()?;
let note_imports = self.import_notes_and_notetypes(&mut media_map)?;
- let imported_decks = self.import_decks_and_configs()?;
- self.import_cards_and_revlog(¬e_imports.id_map, &imported_decks)?;
+ let keep_filtered = self.data.enables_filtered_decks();
+ let imported_decks = self.import_decks_and_configs(keep_filtered)?;
+ self.import_cards_and_revlog(¬e_imports.id_map, &imported_decks, keep_filtered)?;
self.copy_media(&mut media_map)?;
Ok(note_imports.log)
}
@@ -107,6 +108,24 @@ impl ExchangeData {
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.
+ self.contains_scheduling() && self.contains_all_original_decks()
+ }
+
+ fn contains_scheduling(&self) -> bool {
+ !(self.revlog.is_empty() && self.deck_configs.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) -> Result {
diff --git a/rslib/src/import_export/text/csv/import.rs b/rslib/src/import_export/text/csv/import.rs
index a38d6166e..d6385e706 100644
--- a/rslib/src/import_export/text/csv/import.rs
+++ b/rslib/src/import_export/text/csv/import.rs
@@ -158,10 +158,10 @@ impl ColumnContext {
fn foreign_note_from_record(&self, record: &csv::StringRecord) -> ForeignNote {
ForeignNote {
- notetype: str_from_record_column(self.notetype_column, record).into(),
+ notetype: name_or_id_from_record_column(self.notetype_column, record),
fields: self.gather_note_fields(record),
tags: self.gather_tags(record),
- deck: str_from_record_column(self.deck_column, record).into(),
+ deck: name_or_id_from_record_column(self.deck_column, record),
guid: str_from_record_column(self.guid_column, record),
..Default::default()
}
@@ -192,6 +192,10 @@ fn str_from_record_column(column: Option, record: &csv::StringRecord) ->
.to_string()
}
+fn name_or_id_from_record_column(column: Option, record: &csv::StringRecord) -> NameOrId {
+ NameOrId::parse(column.and_then(|i| record.get(i - 1)).unwrap_or_default())
+}
+
pub(super) fn build_csv_reader(
mut reader: impl Read + Seek,
delimiter: Delimiter,
diff --git a/rslib/src/import_export/text/csv/metadata.rs b/rslib/src/import_export/text/csv/metadata.rs
index 10d8985d9..44eaacc72 100644
--- a/rslib/src/import_export/text/csv/metadata.rs
+++ b/rslib/src/import_export/text/csv/metadata.rs
@@ -67,7 +67,6 @@ impl Collection {
maybe_set_fallback_columns(&mut metadata)?;
self.maybe_set_fallback_notetype(&mut metadata, notetype_id)?;
self.maybe_init_notetype_map(&mut metadata)?;
- maybe_set_tags_column(&mut metadata);
self.maybe_set_fallback_deck(&mut metadata)?;
Ok(metadata)
@@ -222,6 +221,7 @@ impl Collection {
);
}
ensure_first_field_is_mapped(&mut global.field_columns, column_len, &meta_columns)?;
+ maybe_set_tags_column(metadata, &meta_columns);
}
Ok(())
}
@@ -388,12 +388,15 @@ fn maybe_set_fallback_delimiter(
Ok(())
}
-fn maybe_set_tags_column(metadata: &mut CsvMetadata) {
+fn maybe_set_tags_column(metadata: &mut CsvMetadata, meta_columns: &HashSet) {
if metadata.tags_column == 0 {
if let Some(CsvNotetype::GlobalNotetype(ref global)) = metadata.notetype {
let max_field = global.field_columns.iter().max().copied().unwrap_or(0);
- if max_field < metadata.column_labels.len() as u32 {
- metadata.tags_column = max_field + 1;
+ for idx in (max_field + 1) as usize..metadata.column_labels.len() {
+ if !meta_columns.contains(&idx) {
+ metadata.tags_column = max_field + 1;
+ break;
+ }
}
}
}
diff --git a/rslib/src/import_export/text/import.rs b/rslib/src/import_export/text/import.rs
index f51770a29..c29e76231 100644
--- a/rslib/src/import_export/text/import.rs
+++ b/rslib/src/import_export/text/import.rs
@@ -129,6 +129,11 @@ impl DeckIdsByNameOrId {
NameOrId::Name(name) => self.names.get(name).copied(),
}
}
+
+ fn insert(&mut self, deck_id: DeckId, name: String) {
+ self.ids.insert(deck_id);
+ self.names.insert(name, deck_id);
+ }
}
impl<'a> Context<'a> {
@@ -195,7 +200,7 @@ impl<'a> Context<'a> {
continue;
}
if let Some(notetype) = self.notetype_for_note(&foreign)? {
- if let Some(deck_id) = self.deck_ids.get(&foreign.deck) {
+ if let Some(deck_id) = self.get_or_create_deck_id(&foreign.deck)? {
let ctx = self.build_note_context(
foreign,
notetype,
@@ -214,6 +219,20 @@ impl<'a> Context<'a> {
Ok(log)
}
+ fn get_or_create_deck_id(&mut self, deck: &NameOrId) -> Result