diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 838793362..190ba2739 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -27,6 +27,7 @@ browsing-change-deck = Change Deck browsing-change-deck2 = Change Deck... browsing-change-note-type = Change Note Type browsing-change-note-type2 = Change Note Type... +browsing-change-notetype = Change Notetype browsing-change-to = Change { $val } to: browsing-clear-unused = Clear Unused browsing-clear-unused-tags = Clear Unused Tags diff --git a/ftl/core/errors.ftl b/ftl/core/errors.ftl index c2cfe9e82..01d084d8c 100644 --- a/ftl/core/errors.ftl +++ b/ftl/core/errors.ftl @@ -7,3 +7,4 @@ errors-100-tags-max = A maximum of 100 tags can be selected. Listing the tags you want instead of the ones you don't want is usually simpler, and there is no need to select child tags if you have selected a parent tag. +errors-multiple-notetypes-selected = Please select notes from only one notetype. diff --git a/rslib/backend.proto b/rslib/backend.proto index 2b30c7319..79c87b6e0 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -17,6 +17,10 @@ message OptionalUInt32 { uint32 val = 1; } +message OptionalUInt32Wrapper { + OptionalUInt32 inner = 1; +} + message Int32 { sint32 val = 1; } @@ -216,6 +220,9 @@ service NotetypesService { rpc RemoveNotetype(NotetypeId) returns (OpChanges); rpc GetAuxNotetypeConfigKey(GetAuxConfigKeyIn) returns (String); rpc GetAuxTemplateConfigKey(GetAuxTemplateConfigKeyIn) returns (String); + rpc GetChangeNotetypeInfo(GetChangeNotetypeInfoIn) + returns (ChangeNotetypeInfo); + rpc ChangeNotetype(ChangeNotetypeIn) returns (OpChanges); } service CardRenderingService { @@ -1636,3 +1643,25 @@ message GetAuxTemplateConfigKeyIn { uint32 card_ordinal = 2; string key = 3; } + +message GetChangeNotetypeInfoIn { + repeated int64 note_ids = 1; + int64 new_notetype_id = 2; +} + +message ChangeNotetypeIn { + repeated int64 note_ids = 1; + repeated OptionalUInt32Wrapper new_fields = 2; + repeated OptionalUInt32Wrapper new_templates = 3; + int64 old_notetype_id = 4; + int64 new_notetype_id = 5; + int64 current_schema = 6; +} + +message ChangeNotetypeInfo { + repeated string old_field_names = 1; + repeated string old_template_names = 2; + repeated string new_field_names = 3; + repeated string new_template_names = 4; + ChangeNotetypeIn input = 5; +} diff --git a/rslib/src/backend/error.rs b/rslib/src/backend/error.rs index 20a02b464..c548d5966 100644 --- a/rslib/src/backend/error.rs +++ b/rslib/src/backend/error.rs @@ -31,6 +31,7 @@ impl AnkiError { AnkiError::ParseNumError => Kind::InvalidInput, AnkiError::InvalidRegex(_) => Kind::InvalidInput, AnkiError::UndoEmpty => Kind::UndoEmpty, + AnkiError::MultipleNotetypesSelected => Kind::InvalidInput, }; pb::BackendError { diff --git a/rslib/src/backend/notes.rs b/rslib/src/backend/notes.rs index a19462425..673c32058 100644 --- a/rslib/src/backend/notes.rs +++ b/rslib/src/backend/notes.rs @@ -131,7 +131,7 @@ impl NotesService for Backend { fn cards_of_note(&self, input: pb::NoteId) -> Result { self.with_col(|col| { col.storage - .all_card_ids_of_note_in_order(NoteId(input.nid)) + .all_card_ids_of_note_in_template_order(NoteId(input.nid)) .map(|v| pb::CardIds { cids: v.into_iter().map(Into::into).collect(), }) diff --git a/rslib/src/backend/notetypes.rs b/rslib/src/backend/notetypes.rs index 02d2c23d0..40c420b9b 100644 --- a/rslib/src/backend/notetypes.rs +++ b/rslib/src/backend/notetypes.rs @@ -1,12 +1,14 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::Backend; +use super::{notes::to_note_ids, Backend}; pub(super) use crate::backend_proto::notetypes_service::Service as NotetypesService; use crate::{ backend_proto as pb, config::get_aux_notetype_config_key, - notetype::{all_stock_notetypes, Notetype, NotetypeSchema11}, + notetype::{ + all_stock_notetypes, ChangeNotetypeInput, Notetype, NotetypeChangeInfo, NotetypeSchema11, + }, prelude::*, }; @@ -153,6 +155,19 @@ impl NotetypesService for Backend { .map(Into::into) }) } + + fn get_change_notetype_info( + &self, + input: pb::GetChangeNotetypeInfoIn, + ) -> Result { + self.with_col(|col| { + col.notetype_change_info(to_note_ids(input.note_ids), input.new_notetype_id.into()) + .map(Into::into) + }) + } + fn change_notetype(&self, input: pb::ChangeNotetypeIn) -> Result { + self.with_col(|col| col.change_notetype_of_notes(input.into()).map(Into::into)) + } } impl From for Notetype { @@ -168,3 +183,69 @@ impl From for Notetype { } } } + +impl From for pb::ChangeNotetypeInfo { + fn from(i: NotetypeChangeInfo) -> Self { + pb::ChangeNotetypeInfo { + old_field_names: i.old_field_names, + old_template_names: i.old_template_names, + new_field_names: i.new_field_names, + new_template_names: i.new_template_names, + input: Some(i.input.into()), + } + } +} + +impl From for ChangeNotetypeInput { + fn from(i: pb::ChangeNotetypeIn) -> Self { + ChangeNotetypeInput { + current_schema: i.current_schema.into(), + note_ids: i.note_ids.into_newtype(NoteId), + old_notetype_id: i.old_notetype_id.into(), + new_notetype_id: i.new_notetype_id.into(), + new_fields: i + .new_fields + .into_iter() + .map(|wrapper| wrapper.inner.map(|v| v.val as usize)) + .collect(), + new_templates: { + let v: Vec<_> = i + .new_templates + .into_iter() + .map(|wrapper| wrapper.inner.map(|v| v.val as usize)) + .collect(); + if v.is_empty() { + None + } else { + Some(v) + } + }, + } + } +} + +impl From for pb::ChangeNotetypeIn { + fn from(i: ChangeNotetypeInput) -> Self { + pb::ChangeNotetypeIn { + current_schema: i.current_schema.into(), + note_ids: i.note_ids.into_iter().map(Into::into).collect(), + old_notetype_id: i.old_notetype_id.into(), + new_notetype_id: i.new_notetype_id.into(), + new_fields: i + .new_fields + .into_iter() + .map(|idx| pb::OptionalUInt32Wrapper { + inner: idx.map(|idx| pb::OptionalUInt32 { val: idx as u32 }), + }) + .collect(), + new_templates: i + .new_templates + .unwrap_or_default() + .into_iter() + .map(|idx| pb::OptionalUInt32Wrapper { + inner: idx.map(|idx| pb::OptionalUInt32 { val: idx as u32 }), + }) + .collect(), + } + } +} diff --git a/rslib/src/error/mod.rs b/rslib/src/error/mod.rs index 54af8e768..b89619fc8 100644 --- a/rslib/src/error/mod.rs +++ b/rslib/src/error/mod.rs @@ -39,6 +39,7 @@ pub enum AnkiError { SearchError(SearchErrorKind), InvalidRegex(String), UndoEmpty, + MultipleNotetypesSelected, } impl Display for AnkiError { @@ -90,7 +91,16 @@ impl AnkiError { AnkiError::ParseNumError => tr.errors_parse_number_fail().into(), AnkiError::FilteredDeckError(err) => err.localized_description(tr), AnkiError::InvalidRegex(err) => format!("
{}
", err), - _ => format!("{:?}", self), + AnkiError::MultipleNotetypesSelected => tr.errors_multiple_notetypes_selected().into(), + AnkiError::IoError(_) + | AnkiError::JsonError(_) + | AnkiError::ProtoError(_) + | AnkiError::Interrupted + | AnkiError::CollectionNotOpen + | AnkiError::CollectionAlreadyOpen + | AnkiError::NotFound + | AnkiError::Existing + | AnkiError::UndoEmpty => format!("{:?}", self), } } } diff --git a/rslib/src/notes/mod.rs b/rslib/src/notes/mod.rs index 2a88a1171..8ce7ef894 100644 --- a/rslib/src/notes/mod.rs +++ b/rslib/src/notes/mod.rs @@ -419,7 +419,7 @@ impl Collection { &mut self, note: &mut Note, original: &Note, - nt: &Notetype, + notetype: &Notetype, usn: Usn, mark_note_modified: bool, normalize_text: bool, @@ -428,7 +428,7 @@ impl Collection { if update_tags { self.canonify_note_tags(note, usn)?; } - note.prepare_for_update(nt, normalize_text)?; + note.prepare_for_update(notetype, normalize_text)?; if mark_note_modified { note.set_modified(usn); } diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index cce6da6d9..a8158a569 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -4,6 +4,7 @@ mod cardgen; mod emptycards; mod fields; +mod notetypechange; mod render; mod schema11; mod schemachange; @@ -11,7 +12,6 @@ mod stock; mod templates; pub(crate) mod undo; -use lazy_static::lazy_static; use std::{ collections::{HashMap, HashSet}, iter::FromIterator, @@ -20,6 +20,8 @@ use std::{ pub(crate) use cardgen::{AlreadyGeneratedCardInfo, CardGenContext}; pub use fields::NoteField; +use lazy_static::lazy_static; +pub use notetypechange::{ChangeNotetypeInput, NotetypeChangeInfo}; pub(crate) use render::RenderCardOutput; pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NotetypeSchema11}; pub use stock::all_stock_notetypes; @@ -123,6 +125,9 @@ impl Collection { /// Saves changes to a note type. This will force a full sync if templates /// or fields have been added/removed/reordered. + /// + /// This does not assign ordinals to the provided notetype, so if you wish + /// to make use of template_idx, the notetype must be fetched again. pub fn update_notetype(&mut self, notetype: &mut Notetype) -> Result> { self.transact(Op::UpdateNotetype, |col| { let original = col diff --git a/rslib/src/notetype/notetypechange.rs b/rslib/src/notetype/notetypechange.rs new file mode 100644 index 000000000..94e465901 --- /dev/null +++ b/rslib/src/notetype/notetypechange.rs @@ -0,0 +1,549 @@ +// Copyright: Ankitects Pty Ltd and contributors +// Lice removed: (), remapped: () removed: (), remapped: () nse: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +//! Updates to notes/cards when a note is moved to a different notetype. + +use std::collections::{HashMap, HashSet}; + +use super::{CardGenContext, Notetype, NotetypeKind}; +use crate::{ + prelude::*, + search::{Node, SearchNode, SortMode, TemplateKind}, + storage::comma_separated_ids, +}; + +#[derive(Debug)] +pub struct ChangeNotetypeInput { + pub current_schema: TimestampMillis, + pub note_ids: Vec, + pub old_notetype_id: NotetypeId, + pub new_notetype_id: NotetypeId, + pub new_fields: Vec>, + pub new_templates: Option>>, +} + +#[derive(Debug)] +pub struct NotetypeChangeInfo { + pub input: ChangeNotetypeInput, + pub old_field_names: Vec, + pub old_template_names: Vec, + pub new_field_names: Vec, + pub new_template_names: Vec, +} + +#[derive(Debug, PartialEq)] +pub struct TemplateMap { + pub removed: Vec, + pub remapped: HashMap, +} + +impl TemplateMap { + fn new(new_templates: Vec>, old_template_count: usize) -> Result { + let mut seen: HashSet = HashSet::new(); + let remapped: HashMap<_, _> = new_templates + .iter() + .enumerate() + .filter_map(|(new_idx, old_idx)| { + if let Some(old_idx) = *old_idx { + seen.insert(old_idx); + if old_idx != new_idx { + return Some((old_idx, new_idx)); + } + } + + None + }) + .collect(); + + let removed: Vec<_> = (0..old_template_count) + .filter(|idx| !seen.contains(&idx)) + .collect(); + if removed.len() == new_templates.len() { + return Err(AnkiError::invalid_input( + "at least one template must be mapped", + )); + } + + Ok(TemplateMap { removed, remapped }) + } +} + +impl Collection { + pub fn notetype_change_info( + &mut self, + note_ids: Vec, + new_notetype_id: NotetypeId, + ) -> Result { + let old_notetype_id = self.get_single_notetype_of_notes(¬e_ids)?; + let old_notetype = self + .get_notetype(old_notetype_id)? + .ok_or(AnkiError::NotFound)?; + let new_notetype = self + .get_notetype(new_notetype_id)? + .ok_or(AnkiError::NotFound)?; + + let current_schema = self.storage.get_collection_timestamps()?.schema_change; + let new_fields = default_field_map(&old_notetype, &new_notetype); + let new_templates = default_template_map(&old_notetype, &new_notetype); + + Ok(NotetypeChangeInfo { + input: ChangeNotetypeInput { + current_schema, + note_ids, + old_notetype_id, + new_notetype_id, + new_fields, + new_templates, + }, + old_field_names: old_notetype.fields.iter().map(|f| f.name.clone()).collect(), + old_template_names: old_notetype + .templates + .iter() + .map(|f| f.name.clone()) + .collect(), + new_field_names: new_notetype.fields.iter().map(|f| f.name.clone()).collect(), + new_template_names: new_notetype + .templates + .iter() + .map(|f| f.name.clone()) + .collect(), + }) + } + + pub fn change_notetype_of_notes(&mut self, input: ChangeNotetypeInput) -> Result> { + self.transact(Op::ChangeNotetype, |col| { + col.change_notetype_of_notes_inner(input) + }) + } +} + +fn default_template_map( + current_notetype: &Notetype, + new_notetype: &Notetype, +) -> Option>> { + if current_notetype.config.kind() == NotetypeKind::Cloze + || new_notetype.config.kind() == NotetypeKind::Cloze + { + // clozes can't be remapped + None + } else { + // name -> (ordinal, is_used) + let mut existing_templates: HashMap<&str, (usize, bool)> = current_notetype + .templates + .iter() + .map(|template| { + ( + template.name.as_str(), + (template.ord.unwrap() as usize, false), + ) + }) + .collect(); + + // match by name + let mut new_templates: Vec<_> = new_notetype + .templates + .iter() + .map(|template| { + existing_templates + .get_mut(template.name.as_str()) + .map(|(idx, used)| { + *used = true; + *idx + }) + }) + .collect(); + + // fill in gaps with any unused templates + let mut remaining_templates: Vec<_> = existing_templates + .values() + .filter_map(|(idx, used)| if !used { Some(idx) } else { None }) + .collect(); + remaining_templates.sort_unstable(); + new_templates + .iter_mut() + .filter(|o| o.is_none()) + .zip(remaining_templates.into_iter()) + .for_each(|(template, old_idx)| *template = Some(*old_idx)); + + Some(new_templates) + } +} + +fn default_field_map(current_notetype: &Notetype, new_notetype: &Notetype) -> Vec> { + // name -> (ordinal, is_used) + let mut existing_fields: HashMap<&str, (usize, bool)> = current_notetype + .fields + .iter() + .map(|field| (field.name.as_str(), (field.ord.unwrap() as usize, false))) + .collect(); + + // match by name + let mut new_fields: Vec<_> = new_notetype + .fields + .iter() + .map(|field| { + existing_fields + .get_mut(field.name.as_str()) + .map(|(idx, used)| { + *used = true; + *idx + }) + }) + .collect(); + + // fill in gaps with any unused fields + let mut remaining_fields: Vec<_> = existing_fields + .values() + .filter_map(|(idx, used)| if !used { Some(idx) } else { None }) + .collect(); + remaining_fields.sort_unstable(); + new_fields + .iter_mut() + .filter(|o| o.is_none()) + .zip(remaining_fields.into_iter()) + .for_each(|(field, old_idx)| *field = Some(*old_idx)); + + new_fields +} + +impl Collection { + /// Return the notetype used by `note_ids`, or an error if not exactly 1 + /// notetype is in use. + fn get_single_notetype_of_notes(&mut self, note_ids: &[NoteId]) -> Result { + if note_ids.is_empty() { + return Err(AnkiError::NotFound); + } + + let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(¬e_ids)).into(); + let note1 = self + .storage + .get_note(*note_ids.first().unwrap())? + .ok_or(AnkiError::NotFound)?; + + if self + .search_notes_unordered(match_all![note1.notetype_id, nids_node])? + .len() + != note_ids.len() + { + Err(AnkiError::MultipleNotetypesSelected) + } else { + Ok(note1.notetype_id) + } + } + + fn change_notetype_of_notes_inner(&mut self, input: ChangeNotetypeInput) -> Result<()> { + if input.current_schema != self.storage.get_collection_timestamps()?.schema_change { + return Err(AnkiError::invalid_input("schema changed")); + } + + let usn = self.usn()?; + self.set_schema_modified()?; + if let Some(new_templates) = input.new_templates { + let old_notetype = self + .get_notetype(input.old_notetype_id)? + .ok_or(AnkiError::NotFound)?; + self.update_cards_for_new_notetype( + &input.note_ids, + old_notetype.templates.len(), + new_templates, + usn, + )?; + } else { + self.maybe_remove_cards_with_missing_template( + &input.note_ids, + input.new_notetype_id, + usn, + )?; + } + self.update_notes_for_new_notetype_and_generate_cards( + &input.note_ids, + &input.new_fields, + input.new_notetype_id, + usn, + )?; + + Ok(()) + } + + /// Rewrite notes to match new notetype, and assigns new notetype id. + /// + /// `new_fields` should be the length of the new notetype's fields, and is a + /// list of the previous field index each field should be mapped to. If None, + /// the field is left empty. + fn update_notes_for_new_notetype_and_generate_cards( + &mut self, + note_ids: &[NoteId], + new_fields: &[Option], + new_notetype_id: NotetypeId, + usn: Usn, + ) -> Result<()> { + let notetype = self + .get_notetype(new_notetype_id)? + .ok_or(AnkiError::NotFound)?; + let last_deck = self.get_last_deck_added_to_for_notetype(notetype.id); + let ctx = CardGenContext::new(¬etype, last_deck, usn); + + for nid in note_ids { + let mut note = self.storage.get_note(*nid)?.unwrap(); + let original = note.clone(); + remap_fields(note.fields_mut(), new_fields); + note.notetype_id = new_notetype_id; + self.update_note_inner_generating_cards( + &ctx, &mut note, &original, true, false, false, + )?; + } + + Ok(()) + } + + fn update_cards_for_new_notetype( + &mut self, + note_ids: &[NoteId], + old_template_count: usize, + new_templates: Vec>, + usn: Usn, + ) -> Result<()> { + let nids: Node = SearchNode::NoteIds(comma_separated_ids(note_ids)).into(); + let map = TemplateMap::new(new_templates, old_template_count)?; + self.remove_unmapped_cards(&map, nids.clone(), usn)?; + self.rewrite_remapped_cards(&map, nids, usn)?; + + Ok(()) + } + + fn remove_unmapped_cards( + &mut self, + map: &TemplateMap, + nids: Node, + usn: Usn, + ) -> Result<(), AnkiError> { + if !map.removed.is_empty() { + let ords = Node::any( + map.removed + .iter() + .map(|o| TemplateKind::Ordinal(*o as u16)) + .map(Into::into), + ); + self.search_cards_into_table(match_all![nids, ords], SortMode::NoOrder)?; + for card in self.storage.all_searched_cards()? { + self.remove_card_and_add_grave_undoable(card, usn)?; + } + self.storage.clear_searched_cards_table()?; + } + + Ok(()) + } + + fn rewrite_remapped_cards( + &mut self, + map: &TemplateMap, + nids: Node, + usn: Usn, + ) -> Result<(), AnkiError> { + if !map.remapped.is_empty() { + let ords = Node::any( + map.remapped + .keys() + .map(|o| TemplateKind::Ordinal(*o as u16)) + .map(Into::into), + ); + self.search_cards_into_table(match_all![nids, ords], SortMode::NoOrder)?; + for mut card in self.storage.all_searched_cards()? { + let original = card.clone(); + card.template_idx = + *map.remapped.get(&(card.template_idx as usize)).unwrap() as u16; + self.update_card_inner(&mut card, original, usn)?; + } + self.storage.clear_searched_cards_table()?; + } + + Ok(()) + } + + /// If provided notetype is a normal notetype, remove any card ordinals that + /// don't have a template associated with them. While recent Anki versions + /// should be able to handle this case, it can cause crashes on older + /// clients. + fn maybe_remove_cards_with_missing_template( + &mut self, + note_ids: &[NoteId], + notetype_id: NotetypeId, + usn: Usn, + ) -> Result<()> { + let notetype = self.get_notetype(notetype_id)?.ok_or(AnkiError::NotFound)?; + + if notetype.config.kind() == NotetypeKind::Normal { + // cloze -> normal change requires clean up + for card in self + .storage + .all_cards_of_notes_above_ordinal(note_ids, notetype.templates.len() - 1)? + { + self.remove_card_and_add_grave_undoable(card, usn)?; + } + self.storage.clear_searched_notes_table()?; + } + + Ok(()) + } +} + +/// Rewrite the field list from a note to match a new notetype's fields. +fn remap_fields(fields: &mut Vec, new_fields: &[Option]) { + *fields = new_fields + .iter() + .map(|field| { + if let Some(idx) = *field { + // clone required as same field can be mapped multiple times + fields.get(idx).map(ToString::to_string).unwrap_or_default() + } else { + String::new() + } + }) + .collect(); +} + +#[cfg(test)] +mod test { + use super::*; + use crate::{collection::open_test_collection, error::Result}; + + #[test] + fn field_map() -> Result<()> { + let mut col = open_test_collection(); + let mut basic = col + .storage + .get_notetype(col.get_current_notetype_id().unwrap())? + .unwrap(); + + // no matching field names; fields are assigned in order + let cloze = col.get_notetype_by_name("Cloze")?.unwrap().as_ref().clone(); + assert_eq!(&default_field_map(&basic, &cloze), &[Some(0), Some(1)]); + + basic.add_field("idx2"); + basic.add_field("idx3"); + basic.add_field("Text"); // 4 + basic.add_field("idx5"); + // re-fetch to get ordinals + col.update_notetype(&mut basic)?; + let basic = col.get_notetype(basic.id)?.unwrap(); + + // if names match, assignments are out of order; unmatched entries + // are filled sequentially + assert_eq!(&default_field_map(&basic, &cloze), &[Some(4), Some(0)]); + + // unmatched entries are filled sequentially until exhausted + assert_eq!( + &default_field_map(&cloze, &basic), + &[ + // front + Some(1), + // back + None, + // idx2 + None, + // idx3 + None, + // text + Some(0), + // idx5 + None, + ] + ); + + Ok(()) + } + + #[test] + fn template_map() -> Result<()> { + let new_templates = vec![None, Some(0)]; + + assert_eq!( + TemplateMap::new(new_templates.clone(), 1)?, + TemplateMap { + removed: vec![], + remapped: vec![(0, 1)].into_iter().collect() + } + ); + + assert_eq!( + TemplateMap::new(new_templates, 2)?, + TemplateMap { + removed: vec![1], + remapped: vec![(0, 1)].into_iter().collect() + } + ); + + Ok(()) + } + + #[test] + fn basic() -> Result<()> { + let mut col = open_test_collection(); + let basic = col.get_notetype_by_name("Basic")?.unwrap(); + let mut note = basic.new_note(); + note.set_field(0, "1")?; + note.set_field(1, "2")?; + col.add_note(&mut note, DeckId(1))?; + + let basic2 = col + .get_notetype_by_name("Basic (and reversed card)")? + .unwrap(); + let mut info = col.notetype_change_info(vec![note.id], basic2.id)?; + + // switch the existing card to ordinal 2 + let first_card = col.storage.all_cards_of_note(note.id)?[0].clone(); + assert_eq!(first_card.template_idx, 0); + let templates = info.input.new_templates.as_mut().unwrap(); + *templates = vec![None, Some(0)]; + col.change_notetype_of_notes(info.input)?; + + // cards arrive in creation order, so the existing card will come first + let cards = col.storage.all_cards_of_note(note.id)?; + assert_eq!(cards[0].id, first_card.id); + assert_eq!(cards[0].template_idx, 1); + + // a new forward card should also have been generated + assert_eq!(cards[1].template_idx, 0); + assert_ne!(cards[1].id, first_card.id); + + Ok(()) + } + + #[test] + fn cloze() -> Result<()> { + let mut col = open_test_collection(); + let basic = col + .get_notetype_by_name("Basic (and reversed card)")? + .unwrap(); + let mut note = basic.new_note(); + note.set_field(0, "1")?; + note.set_field(1, "2")?; + col.add_note(&mut note, DeckId(1))?; + + let cloze = col.get_notetype_by_name("Cloze")?.unwrap(); + + // changing to cloze should leave all the existing cards alone + let info = col.notetype_change_info(vec![note.id], cloze.id)?; + col.change_notetype_of_notes(info.input)?; + let cards = col.storage.all_cards_of_note(note.id)?; + assert_eq!(cards.len(), 2); + + // and back again should also work + let info = col.notetype_change_info(vec![note.id], basic.id)?; + col.change_notetype_of_notes(info.input)?; + let cards = col.storage.all_cards_of_note(note.id)?; + assert_eq!(cards.len(), 2); + + // but any cards above the available templates should be removed when converting from cloze->normal + let info = col.notetype_change_info(vec![note.id], cloze.id)?; + col.change_notetype_of_notes(info.input)?; + + let basic1 = col.get_notetype_by_name("Basic")?.unwrap(); + let info = col.notetype_change_info(vec![note.id], basic1.id)?; + col.change_notetype_of_notes(info.input)?; + let cards = col.storage.all_cards_of_note(note.id)?; + assert_eq!(cards.len(), 1); + + Ok(()) + } +} diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs index 033fbd494..0893e9607 100644 --- a/rslib/src/notetype/schemachange.rs +++ b/rslib/src/notetype/schemachange.rs @@ -1,6 +1,8 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +//! Updates to notes/cards when the structure of a notetype is changed. + use std::collections::HashMap; use super::{CardGenContext, Notetype}; diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 95ebee7ae..ddf94de33 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -12,6 +12,7 @@ pub enum Op { AnswerCard, BuildFilteredDeck, Bury, + ChangeNotetype, ClearUnusedTags, EmptyFilteredDeck, ExpandCollapse, @@ -82,6 +83,7 @@ impl Op { Op::UpdateNotetype => tr.actions_update_notetype(), Op::UpdateConfig => tr.actions_update_config(), Op::Custom(name) => name.into(), + Op::ChangeNotetype => tr.browsing_change_notetype(), } .into() } diff --git a/rslib/src/scheduler/queue/undo.rs b/rslib/src/scheduler/queue/undo.rs index 617f73188..ca1564d57 100644 --- a/rslib/src/scheduler/queue/undo.rs +++ b/rslib/src/scheduler/queue/undo.rs @@ -104,7 +104,7 @@ mod test { // get the first card let queued = col.get_next_card()?.unwrap(); let cid = queued.card.id; - let sibling_cid = col.storage.all_card_ids_of_note_in_order(nid)?[1]; + let sibling_cid = col.storage.all_card_ids_of_note_in_template_order(nid)?[1]; let assert_initial_state = |col: &mut Collection| -> Result<()> { let first = col.storage.get_card(cid)?.unwrap(); diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 16f3d670d..f5eb9ef6f 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -344,7 +344,25 @@ impl super::SqliteStorage { .collect() } - pub(crate) fn all_card_ids_of_note_in_order(&self, nid: NoteId) -> Result> { + pub(crate) fn all_cards_of_notes_above_ordinal( + &mut self, + note_ids: &[NoteId], + ordinal: usize, + ) -> Result> { + self.set_search_table_to_note_ids(note_ids)?; + self.db + .prepare_cached(concat!( + include_str!("get_card.sql"), + " where nid in (select nid from search_nids) and ord > ?" + ))? + .query_and_then(&[ordinal as i64], |r| row_to_card(r).map_err(Into::into))? + .collect() + } + + pub(crate) fn all_card_ids_of_note_in_template_order( + &self, + nid: NoteId, + ) -> Result> { self.db .prepare_cached("select id from cards where nid = ? order by ord")? .query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))? diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index 3886f078b..f1e9dc630 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -21,19 +21,37 @@ use std::fmt::Write; pub(crate) use sqlite::SqliteStorage; pub(crate) use sync::open_and_check_sqlite_file; -// Write a list of IDs as '(x,y,...)' into the provided string. +/// Write a list of IDs as '(x,y,...)' into the provided string. pub(crate) fn ids_to_string(buf: &mut String, ids: &[T]) where T: std::fmt::Display, { buf.push('('); + write_comma_separated_ids(buf, ids); + buf.push(')'); +} + +/// Write a list of Ids as 'x,y,...' into the provided string. +pub(crate) fn write_comma_separated_ids(buf: &mut String, ids: &[T]) +where + T: std::fmt::Display, +{ if !ids.is_empty() { for id in ids.iter().skip(1) { write!(buf, "{},", id).unwrap(); } write!(buf, "{}", ids[0]).unwrap(); } - buf.push(')'); +} + +pub(crate) fn comma_separated_ids(ids: &[T]) -> String +where + T: std::fmt::Display, +{ + let mut buf = String::new(); + write_comma_separated_ids(&mut buf, ids); + + buf } #[cfg(test)] diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 6ddb8fc4a..69b99102e 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -225,7 +225,7 @@ impl super::SqliteStorage { Ok(()) } - fn clear_searched_notes_table(&self) -> Result<()> { + pub(crate) fn clear_searched_notes_table(&self) -> Result<()> { self.db .execute("drop table if exists search_nids", NO_PARAMS)?; Ok(()) @@ -234,7 +234,8 @@ impl super::SqliteStorage { /// Injects the provided card IDs into the search_nids table, for /// when ids have arrived outside of a search. /// Clear with clear_searched_notes_table(). - fn set_search_table_to_note_ids(&mut self, notes: &[NoteId]) -> Result<()> { + /// WARNING: the column name is nid, not id. + pub(crate) fn set_search_table_to_note_ids(&mut self, notes: &[NoteId]) -> Result<()> { self.setup_searched_notes_table()?; let mut stmt = self .db