From 83bcb084fe6d9a570e55f3edc2e89a672e08d0cd Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 20 Apr 2020 21:32:55 +1000 Subject: [PATCH] template changes and card generation Cloze cards are not yet supported, missing decks are not handled, and more testing is still required. --- rslib/Cargo.toml | 1 + rslib/src/card.rs | 3 +- rslib/src/config.rs | 10 + rslib/src/notes.rs | 49 ++-- rslib/src/notetype/cardgen.rs | 211 ++++++++++++++++++ rslib/src/notetype/cardgeninfo.rs | 85 ------- rslib/src/notetype/mod.rs | 7 +- rslib/src/notetype/schemachange.rs | 190 ++++++++++++++-- rslib/src/search/sqlwriter.rs | 37 +-- rslib/src/storage/mod.rs | 39 ++++ .../notetype/delete_cards_for_template.sql | 10 + rslib/src/storage/notetype/existing_cards.sql | 27 +++ rslib/src/storage/notetype/mod.rs | 81 ++++++- 13 files changed, 579 insertions(+), 171 deletions(-) create mode 100644 rslib/src/notetype/cardgen.rs delete mode 100644 rslib/src/notetype/cardgeninfo.rs create mode 100644 rslib/src/storage/notetype/delete_cards_for_template.sql create mode 100644 rslib/src/storage/notetype/existing_cards.sql diff --git a/rslib/Cargo.toml b/rslib/Cargo.toml index 9e69275f4..064430f96 100644 --- a/rslib/Cargo.toml +++ b/rslib/Cargo.toml @@ -45,6 +45,7 @@ unicase = "=2.6.0" futures = "0.3.4" rand = "0.7.3" num-integer = "0.1.42" +itertools = "0.9.0" # pinned until rusqlite 0.22 comes out [target.'cfg(target_vendor="apple")'.dependencies.rusqlite] diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 6cc5c02ac..b4a8fa99d 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -100,11 +100,12 @@ impl Undoable for UpdateCardUndo { } impl Card { - pub fn new(nid: NoteID, ord: u16, deck_id: DeckID) -> Self { + pub fn new(nid: NoteID, ord: u16, deck_id: DeckID, due: i32) -> Self { let mut card = Card::default(); card.nid = nid; card.ord = ord; card.did = deck_id; + card.due = due; card } } diff --git a/rslib/src/config.rs b/rslib/src/config.rs index 01a1244b1..e1474e3ce 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -39,6 +39,7 @@ pub(crate) enum ConfigKey { Rollover, LocalOffset, CurrentNoteTypeID, + NextNewCardPosition, } impl From for &'static str { @@ -51,6 +52,7 @@ impl From for &'static str { ConfigKey::Rollover => "rollover", ConfigKey::LocalOffset => "localOffset", ConfigKey::CurrentNoteTypeID => "curModel", + ConfigKey::NextNewCardPosition => "nextPos", } } } @@ -132,6 +134,14 @@ impl Collection { pub(crate) fn set_current_notetype_id(&self, id: NoteTypeID) -> Result<()> { self.set_config(ConfigKey::CurrentNoteTypeID, &id) } + + pub(crate) fn get_and_update_next_card_position(&self) -> Result { + let pos: u32 = self + .get_config_optional(ConfigKey::NextNewCardPosition) + .unwrap_or_default(); + self.set_config(ConfigKey::NextNewCardPosition, &pos.wrapping_add(1))?; + Ok(pos) + } } #[derive(Deserialize, PartialEq, Debug)] diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index 750a6dbb3..7cb3325aa 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -2,7 +2,6 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::{ - card::Card, collection::Collection, define_newtype, err::{AnkiError, Result}, @@ -134,36 +133,42 @@ impl Collection { pub fn add_note(&mut self, note: &mut Note) -> Result<()> { self.transact(None, |col| { let nt = col - .storage .get_notetype(note.ntid)? .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; - - let cardgen = CardGenContext::new(&nt, col.usn()?); - col.add_note_inner(note, &cardgen) + let ctx = CardGenContext::new(&nt, col.usn()?); + col.add_note_inner(&ctx, note) }) } - pub(crate) fn add_note_inner( - &mut self, - note: &mut Note, - cardgen: &CardGenContext, - ) -> Result<()> { - let nt = cardgen.notetype; - note.prepare_for_update(nt, cardgen.usn)?; - let nonempty_fields = note.nonempty_fields(&cardgen.notetype.fields); - let cards = - cardgen.new_cards_required(&nonempty_fields, nt.target_deck_id(), &Default::default()); + pub(crate) fn add_note_inner(&mut self, ctx: &CardGenContext, note: &mut Note) -> Result<()> { + note.prepare_for_update(&ctx.notetype, ctx.usn)?; + let cards = ctx.new_cards_required(note, Default::default()); if cards.is_empty() { return Err(AnkiError::NoCardsGenerated); } - - // add the note + // add note first, as we need the allocated ID for the cards self.storage.add_note(note)?; - // and its associated cards - for (card_ord, target_deck_id) in cards { - let mut card = Card::new(note.id, card_ord as u16, target_deck_id); - self.add_card(&mut card)?; - } + self.add_generated_cards(ctx, note.id, &cards) + } + + pub fn update_note(&mut self, note: &mut Note) -> Result<()> { + self.transact(None, |col| { + let nt = col + .get_notetype(note.ntid)? + .ok_or_else(|| AnkiError::invalid_input("missing note type"))?; + let ctx = CardGenContext::new(&nt, col.usn()?); + col.update_note_inner(&ctx, note) + }) + } + + pub(crate) fn update_note_inner( + &mut self, + ctx: &CardGenContext, + note: &mut Note, + ) -> Result<()> { + note.prepare_for_update(ctx.notetype, ctx.usn)?; + self.generate_cards_for_existing_note(ctx, note)?; + self.storage.update_note(note)?; Ok(()) } diff --git a/rslib/src/notetype/cardgen.rs b/rslib/src/notetype/cardgen.rs new file mode 100644 index 000000000..960472f30 --- /dev/null +++ b/rslib/src/notetype/cardgen.rs @@ -0,0 +1,211 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::NoteType; +use crate::{ + card::{Card, CardID}, + collection::Collection, + decks::DeckID, + err::Result, + notes::{Note, NoteID}, + template::ParsedTemplate, + types::Usn, +}; +use itertools::Itertools; +use std::collections::HashSet; + +/// Info about an existing card required when generating new cards +pub(crate) struct AlreadyGeneratedCardInfo { + pub id: CardID, + pub nid: NoteID, + pub ord: u32, + pub original_deck_id: DeckID, + pub position_if_new: Option, +} + +#[derive(Debug)] +pub(crate) struct CardToGenerate { + pub ord: u32, + pub did: Option, + pub due: Option, +} + +/// Info required to determine whether a particular card ordinal should exist, +/// and which deck it should be placed in. +pub(crate) struct SingleCardGenContext<'a> { + template: Option>, + target_deck_id: Option, +} + +/// Info required to determine which cards should be generated when note added/updated, +/// and where they should be placed. +pub(crate) struct CardGenContext<'a> { + pub usn: Usn, + pub notetype: &'a NoteType, + cards: Vec>, +} + +impl CardGenContext<'_> { + pub(crate) fn new(nt: &NoteType, usn: Usn) -> CardGenContext<'_> { + CardGenContext { + usn, + notetype: &nt, + cards: nt + .templates + .iter() + .map(|tmpl| SingleCardGenContext { + template: tmpl.parsed_question(), + target_deck_id: tmpl.target_deck_id(), + }) + .collect(), + } + } + + /// If template[ord] generates a non-empty question given nonempty_fields, return the provided + /// deck id, or an overriden one. If question is empty, return None. + fn is_nonempty(&self, card_ord: usize, nonempty_fields: &HashSet<&str>) -> bool { + let card = &self.cards[card_ord]; + let template = match card.template { + Some(ref template) => template, + None => { + // template failed to parse; card can not be generated + return false; + } + }; + + template.renders_with_fields(&nonempty_fields) + } + + /// Returns the cards that need to be generated for the provided note. + pub(crate) fn new_cards_required( + &self, + note: &Note, + existing: &[AlreadyGeneratedCardInfo], + ) -> Vec { + let nonempty_fields = note.nonempty_fields(&self.notetype.fields); + let extracted = extract_data_from_existing_cards(existing); + self.new_cards_required_inner(&nonempty_fields, &extracted) + } + + fn new_cards_required_inner( + &self, + nonempty_fields: &HashSet<&str>, + extracted: &ExtractedCardInfo, + ) -> Vec { + self.cards + .iter() + .enumerate() + .filter_map(|(ord, card)| { + if !extracted.existing_ords.contains(&(ord as u32)) + && self.is_nonempty(ord, &nonempty_fields) + { + Some(CardToGenerate { + ord: ord as u32, + did: card.target_deck_id.or(extracted.deck_id), + due: extracted.due, + }) + } else { + None + } + }) + .collect() + } +} + +// this could be reworked in the future to avoid the extra vec allocation +fn group_generated_cards_by_note( + items: Vec, +) -> Vec<(NoteID, Vec)> { + let mut out = vec![]; + for (key, group) in &items.into_iter().group_by(|c| c.nid) { + out.push((key, group.collect())); + } + out +} + +#[derive(Debug, PartialEq, Default)] +pub(crate) struct ExtractedCardInfo { + // if set, the due position new cards should be given + pub due: Option, + // if set, the deck all current cards are in + pub deck_id: Option, + pub existing_ords: HashSet, +} + +pub(crate) fn extract_data_from_existing_cards( + cards: &[AlreadyGeneratedCardInfo], +) -> ExtractedCardInfo { + let mut due = None; + let mut deck_ids = HashSet::new(); + for card in cards { + if due.is_none() && card.position_if_new.is_some() { + due = card.position_if_new; + } + deck_ids.insert(card.original_deck_id); + } + let existing_ords: HashSet<_> = cards.iter().map(|c| c.ord).collect(); + ExtractedCardInfo { + due, + deck_id: if deck_ids.len() == 1 { + deck_ids.into_iter().next() + } else { + None + }, + existing_ords, + } +} + +impl Collection { + pub(crate) fn generate_cards_for_existing_note( + &mut self, + ctx: &CardGenContext, + note: &Note, + ) -> Result<()> { + let existing = self.storage.existing_cards_for_note(note.id)?; + let cards = ctx.new_cards_required(note, &existing); + if cards.is_empty() { + return Ok(()); + } + self.add_generated_cards(ctx, note.id, &cards) + } + + pub(crate) fn generate_cards_for_notetype(&mut self, ctx: &CardGenContext) -> Result<()> { + let existing_cards = self.storage.existing_cards_for_notetype(ctx.notetype.id)?; + let by_note = group_generated_cards_by_note(existing_cards); + for (nid, existing_cards) in by_note { + if existing_cards.len() == ctx.notetype.templates.len() { + // nothing to do + continue; + } + let note = self.storage.get_note(nid)?.unwrap(); + self.generate_cards_for_existing_note(ctx, ¬e)?; + } + + Ok(()) + } + + pub(crate) fn add_generated_cards( + &mut self, + ctx: &CardGenContext, + nid: NoteID, + cards: &[CardToGenerate], + ) -> Result<()> { + let mut next_pos = None; + for c in cards { + let did = c.did.unwrap_or_else(|| ctx.notetype.target_deck_id()); + let due = c.due.unwrap_or_else(|| { + if next_pos.is_none() { + next_pos = Some(self.get_and_update_next_card_position().unwrap_or(0)); + } + next_pos.unwrap() + }); + let mut card = Card::new(nid, c.ord as u16, did, due as i32); + self.add_card(&mut card)?; + } + + Ok(()) + } +} + +// fixme: deal with case where invalid deck pointed to +// fixme: cloze cards, & avoid template count comparison for cloze diff --git a/rslib/src/notetype/cardgeninfo.rs b/rslib/src/notetype/cardgeninfo.rs deleted file mode 100644 index 2b558c2ac..000000000 --- a/rslib/src/notetype/cardgeninfo.rs +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright: Ankitects Pty Ltd and contributors -// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - -use super::NoteType; -use crate::{decks::DeckID, template::ParsedTemplate, types::Usn}; -use std::collections::HashSet; - -/// Info required to determine whether a particular card ordinal should exist, -/// and which deck it should be placed in. -pub(crate) struct SingleCardGenContext<'a> { - template: Option>, - target_deck_id: Option, -} - -/// Info required to determine which cards should be generated when note added/updated, -/// and where they should be placed. -pub(crate) struct CardGenContext<'a> { - pub usn: Usn, - pub notetype: &'a NoteType, - cards: Vec>, -} - -impl CardGenContext<'_> { - pub(crate) fn new(nt: &NoteType, usn: Usn) -> CardGenContext<'_> { - CardGenContext { - usn, - notetype: &nt, - cards: nt - .templates - .iter() - .map(|tmpl| SingleCardGenContext { - template: tmpl.parsed_question(), - target_deck_id: tmpl.target_deck_id(), - }) - .collect(), - } - } - - /// If template[ord] generates a non-empty question given nonempty_fields, return the provided - /// deck id, or an overriden one. If question is empty, return None. - pub fn deck_id_if_nonempty( - &self, - card_ord: usize, - nonempty_fields: &HashSet<&str>, - target_deck_id: DeckID, - ) -> Option { - let card = &self.cards[card_ord]; - let template = match card.template { - Some(ref template) => template, - None => { - // template failed to parse; card can not be generated - return None; - } - }; - - if template.renders_with_fields(&nonempty_fields) { - Some(card.target_deck_id.unwrap_or(target_deck_id)) - } else { - None - } - } - - /// Return a list of (ordinal, deck id) for any new cards not in existing_ords - /// that are non-empty, and thus need to be added. - pub fn new_cards_required( - &self, - nonempty_fields: &HashSet<&str>, - target_deck_id: DeckID, - existing_ords: &HashSet, - ) -> Vec<(usize, DeckID)> { - self.cards - .iter() - .enumerate() - .filter_map(|(ord, card)| { - let deck_id = card.target_deck_id.unwrap_or(target_deck_id); - if existing_ords.contains(&(ord as u16)) { - None - } else { - self.deck_id_if_nonempty(ord, nonempty_fields, deck_id) - .map(|did| (ord, did)) - } - }) - .collect() - } -} diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index d21da9846..1f91f1aaa 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -mod cardgeninfo; +mod cardgen; mod fields; mod schema11; mod schemachange; @@ -13,7 +13,7 @@ pub use crate::backend_proto::{ CardRequirement, CardTemplateConfig, NoteFieldConfig, NoteType as NoteTypeProto, NoteTypeConfig, }; -pub(crate) use cardgeninfo::CardGenContext; +pub(crate) use cardgen::{AlreadyGeneratedCardInfo, CardGenContext}; pub use fields::NoteField; pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NoteTypeSchema11}; pub use stock::all_stock_notetypes; @@ -199,11 +199,10 @@ impl Collection { pub fn update_notetype(&mut self, nt: &mut NoteType) -> Result<()> { self.transact(None, |col| { let existing_notetype = col - .storage .get_notetype(nt.id)? .ok_or_else(|| AnkiError::invalid_input("no such notetype"))?; col.update_notes_for_changed_fields(nt, existing_notetype.fields.len())?; - // fixme: card templates + col.update_cards_for_changed_templates(nt, existing_notetype.templates.len())?; // fixme: update cache instead of clearing col.state.notetype_cache.remove(&nt.id); diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs index 1c69deedd..602b9ceb5 100644 --- a/rslib/src/notetype/schemachange.rs +++ b/rslib/src/notetype/schemachange.rs @@ -1,45 +1,71 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::{NoteField, NoteType}; +use super::{CardGenContext, NoteType}; use crate::{collection::Collection, err::Result}; -/// If any fields added, removed or reordered, returns a list of the new -/// field length, comprised of the original ordinals. -fn field_change_map(fields: &[NoteField], previous_field_count: usize) -> Option>> { - let map: Vec<_> = fields.iter().map(|f| f.ord).collect(); - let changed = map.len() != previous_field_count - || map +/// True if any ordinals added, removed or reordered. +fn ords_changed(ords: &[Option], previous_len: usize) -> bool { + ords.len() != previous_len + || ords .iter() .enumerate() - .any(|(idx, f)| f != &Some(idx as u32)); - if changed { - Some(map) - } else { - None + .any(|(idx, &ord)| ord != Some(idx as u32)) +} + +#[derive(Default, PartialEq, Debug)] +struct TemplateOrdChanges { + added: Vec, + removed: Vec, + // map of old->new + moved: Vec<(u32, u32)>, +} + +impl TemplateOrdChanges { + fn new(ords: Vec>, previous_len: u32) -> Self { + let mut changes = TemplateOrdChanges::default(); + let mut removed: Vec<_> = (0..previous_len).map(|v| Some(v as u32)).collect(); + for (idx, old_ord) in ords.into_iter().enumerate() { + if let Some(old_ord) = old_ord { + if let Some(entry) = removed.get_mut(old_ord as usize) { + // guard required to ensure we don't panic if invalid high ordinal received + *entry = None; + } + if old_ord == idx as u32 { + // no action + } else { + changes.moved.push((old_ord as u32, idx as u32)); + } + } else { + changes.added.push(idx as u32); + } + } + + changes.removed = removed.into_iter().filter_map(|v| v).collect(); + + changes } } impl Collection { - /// Caller must create transaction + /// Rewrite notes to match the updated field schema. + /// Caller must create transaction. pub(crate) fn update_notes_for_changed_fields( &mut self, nt: &NoteType, previous_field_count: usize, ) -> Result<()> { - let change_map = match field_change_map(&nt.fields, previous_field_count) { - None => { - // nothing to do - return Ok(()); - } - Some(map) => map, - }; + let ords: Vec<_> = nt.fields.iter().map(|f| f.ord).collect(); + if !ords_changed(&ords, previous_field_count) { + // nothing to do + return Ok(()); + } let nids = self.search_notes_only(&format!("mid:{}", nt.id))?; let usn = self.usn()?; for nid in nids { let mut note = self.storage.get_note(nid)?.unwrap(); - note.fields = change_map + note.fields = ords .iter() .map(|f| { if let Some(idx) = f { @@ -58,12 +84,97 @@ impl Collection { } Ok(()) } + + /// Update cards after card templates added, removed or reordered. + /// Does not remove cards where the template still exists but creates an empty card. + /// Caller must create transaction. + pub(crate) fn update_cards_for_changed_templates( + &mut self, + nt: &NoteType, + previous_template_count: usize, + ) -> Result<()> { + let ords: Vec<_> = nt.templates.iter().map(|f| f.ord).collect(); + if !ords_changed(&ords, previous_template_count) { + // nothing to do + return Ok(()); + } + + let changes = TemplateOrdChanges::new(ords, previous_template_count as u32); + if !changes.removed.is_empty() { + self.storage + .remove_cards_for_deleted_templates(nt.id, &changes.removed)?; + } + if !changes.moved.is_empty() { + self.storage + .move_cards_for_repositioned_templates(nt.id, &changes.moved)?; + } + + let ctx = CardGenContext::new(nt, self.usn()?); + self.generate_cards_for_notetype(&ctx)?; + + Ok(()) + } } #[cfg(test)] mod test { - use crate::collection::open_test_collection; - use crate::err::Result; + use super::{ords_changed, TemplateOrdChanges}; + use crate::{collection::open_test_collection, err::Result, search::SortMode}; + + #[test] + fn ord_changes() { + assert_eq!(ords_changed(&[Some(0), Some(1)], 2), false); + assert_eq!(ords_changed(&[Some(0), Some(1)], 1), true); + assert_eq!(ords_changed(&[Some(1), Some(0)], 2), true); + assert_eq!(ords_changed(&[None, Some(1)], 2), true); + assert_eq!(ords_changed(&[Some(0), Some(1), None], 2), true); + } + + #[test] + fn template_changes() { + assert_eq!( + TemplateOrdChanges::new(vec![Some(0), Some(1)], 2), + TemplateOrdChanges::default(), + ); + assert_eq!( + TemplateOrdChanges::new(vec![Some(0), Some(1)], 3), + TemplateOrdChanges { + removed: vec![2], + ..Default::default() + } + ); + assert_eq!( + TemplateOrdChanges::new(vec![Some(1)], 2), + TemplateOrdChanges { + removed: vec![0], + moved: vec![(1, 0)], + ..Default::default() + } + ); + assert_eq!( + TemplateOrdChanges::new(vec![Some(0), None], 1), + TemplateOrdChanges { + added: vec![1], + ..Default::default() + } + ); + assert_eq!( + TemplateOrdChanges::new(vec![Some(2), None, Some(0)], 2), + TemplateOrdChanges { + added: vec![1], + moved: vec![(2, 0), (0, 2)], + removed: vec![1], + } + ); + assert_eq!( + TemplateOrdChanges::new(vec![None, Some(2), None, Some(4)], 5), + TemplateOrdChanges { + added: vec![0, 2], + moved: vec![(2, 1), (4, 3)], + removed: vec![0, 1, 3], + } + ); + } #[test] fn fields() -> Result<()> { @@ -94,4 +205,37 @@ mod test { Ok(()) } + + #[test] + fn cards() -> Result<()> { + let mut col = open_test_collection(); + let mut nt = col + .storage + .get_notetype(col.get_current_notetype_id().unwrap())? + .unwrap(); + let mut note = nt.new_note(); + assert_eq!(note.fields.len(), 2); + note.fields = vec!["one".into(), "two".into()]; + col.add_note(&mut note)?; + + assert_eq!( + col.search_cards(&format!("nid:{}", note.id), SortMode::NoOrder) + .unwrap() + .len(), + 1 + ); + + // add an extra card template + nt.add_template("card 2", "{{Front}}", ""); + col.update_notetype(&mut nt)?; + + assert_eq!( + col.search_cards(&format!("nid:{}", note.id), SortMode::NoOrder) + .unwrap() + .len(), + 2 + ); + + Ok(()) + } } diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 9026e4aa6..17a491051 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -10,7 +10,9 @@ use crate::notes::field_checksum; use crate::notetype::NoteTypeID; use crate::text::matches_wildcard; use crate::text::without_combining; -use crate::{collection::Collection, text::strip_html_preserving_image_filenames}; +use crate::{ + collection::Collection, storage::ids_to_string, text::strip_html_preserving_image_filenames, +}; use lazy_static::lazy_static; use regex::{Captures, Regex}; use std::fmt::Write; @@ -373,21 +375,6 @@ impl SqlWriter<'_> { } } -// Write a list of IDs as '(x,y,...)' into the provided string. -fn ids_to_string(buf: &mut String, ids: &[T]) -where - T: std::fmt::Display, -{ - buf.push('('); - if !ids.is_empty() { - for id in ids.iter().skip(1) { - write!(buf, "{},", id).unwrap(); - } - write!(buf, "{}", ids[0]).unwrap(); - } - buf.push(')'); -} - /// Convert a string with _, % or * characters into a regex. fn glob_to_re(glob: &str) -> Option { if !glob.contains(|c| c == '_' || c == '*' || c == '%') { @@ -426,7 +413,6 @@ fn glob_to_re(glob: &str) -> Option { #[cfg(test)] mod test { - use super::ids_to_string; use crate::{ collection::{open_collection, Collection}, i18n::I18n, @@ -436,23 +422,6 @@ mod test { use std::{fs, path::PathBuf}; use tempfile::tempdir; - #[test] - fn ids_string() { - let mut s = String::new(); - ids_to_string::(&mut s, &[]); - assert_eq!(s, "()"); - s.clear(); - ids_to_string(&mut s, &[7]); - assert_eq!(s, "(7)"); - s.clear(); - ids_to_string(&mut s, &[7, 6]); - assert_eq!(s, "(6,7)"); - s.clear(); - ids_to_string(&mut s, &[7, 6, 5]); - assert_eq!(s, "(6,5,7)"); - s.clear(); - } - use super::super::parser::parse; use super::*; diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index dc19df4b1..2192be013 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -12,3 +12,42 @@ mod tag; mod upgrades; pub(crate) use sqlite::SqliteStorage; + +use std::fmt::Write; + +// 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('('); + if !ids.is_empty() { + for id in ids.iter().skip(1) { + write!(buf, "{},", id).unwrap(); + } + write!(buf, "{}", ids[0]).unwrap(); + } + buf.push(')'); +} + +#[cfg(test)] +mod test { + use super::ids_to_string; + + #[test] + fn ids_string() { + let mut s = String::new(); + ids_to_string::(&mut s, &[]); + assert_eq!(s, "()"); + s.clear(); + ids_to_string(&mut s, &[7]); + assert_eq!(s, "(7)"); + s.clear(); + ids_to_string(&mut s, &[7, 6]); + assert_eq!(s, "(6,7)"); + s.clear(); + ids_to_string(&mut s, &[7, 6, 5]); + assert_eq!(s, "(6,5,7)"); + s.clear(); + } +} diff --git a/rslib/src/storage/notetype/delete_cards_for_template.sql b/rslib/src/storage/notetype/delete_cards_for_template.sql new file mode 100644 index 000000000..38748ebf5 --- /dev/null +++ b/rslib/src/storage/notetype/delete_cards_for_template.sql @@ -0,0 +1,10 @@ +delete from cards +where + nid in ( + select + id + from notes + where + mid = ? + ) + and ord = ?; \ No newline at end of file diff --git a/rslib/src/storage/notetype/existing_cards.sql b/rslib/src/storage/notetype/existing_cards.sql new file mode 100644 index 000000000..110c80f8b --- /dev/null +++ b/rslib/src/storage/notetype/existing_cards.sql @@ -0,0 +1,27 @@ +select + id, + nid, + ord, + -- original deck + ( + case + odid + when 0 then did + else odid + end + ), + -- new position if card is empty + ( + case + type + when 0 then ( + case + odue + when 0 then due + else odue + end + ) + else null + end + ) +from cards c \ No newline at end of file diff --git a/rslib/src/storage/notetype/mod.rs b/rslib/src/storage/notetype/mod.rs index 072e485c6..13e412aa5 100644 --- a/rslib/src/storage/notetype/mod.rs +++ b/rslib/src/storage/notetype/mod.rs @@ -1,10 +1,14 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use super::SqliteStorage; +use super::{ids_to_string, SqliteStorage}; use crate::{ err::{AnkiError, DBErrorKind, Result}, - notetype::{CardTemplate, CardTemplateConfig, NoteField, NoteFieldConfig, NoteTypeConfig}, + notes::NoteID, + notetype::{ + AlreadyGeneratedCardInfo, CardTemplate, CardTemplateConfig, NoteField, NoteFieldConfig, + NoteTypeConfig, + }, notetype::{NoteType, NoteTypeID, NoteTypeSchema11}, timestamp::TimestampMillis, }; @@ -26,6 +30,16 @@ fn row_to_notetype_core(row: &Row) -> Result { }) } +fn row_to_existing_card(row: &Row) -> Result { + Ok(AlreadyGeneratedCardInfo { + id: row.get(0)?, + nid: row.get(1)?, + ord: row.get(2)?, + original_deck_id: row.get(3)?, + position_if_new: row.get(4)?, + }) +} + impl SqliteStorage { pub(crate) fn get_notetype(&self, ntid: NoteTypeID) -> Result> { match self.get_notetype_core(ntid)? { @@ -165,6 +179,69 @@ impl SqliteStorage { Ok(()) } + pub(crate) fn remove_cards_for_deleted_templates( + &self, + ntid: NoteTypeID, + ords: &[u32], + ) -> Result<()> { + let mut stmt = self + .db + .prepare(include_str!("delete_cards_for_template.sql"))?; + for ord in ords { + stmt.execute(params![ntid, ord])?; + } + Ok(()) + } + + pub(crate) fn move_cards_for_repositioned_templates( + &self, + ntid: NoteTypeID, + changes: &[(u32, u32)], + ) -> Result<()> { + let case_clauses: Vec<_> = changes + .iter() + .map(|(old, new)| format!("when {} then {}", old, new)) + .collect(); + let mut sql = format!( + "update cards set ord = (case ord {} end) +where nid in (select id from notes where mid = ?) +and ord in ", + case_clauses.join(" ") + ); + ids_to_string( + &mut sql, + &changes.iter().map(|(old, _)| old).collect::>(), + ); + self.db.prepare(&sql)?.execute(&[ntid])?; + Ok(()) + } + + pub(crate) fn existing_cards_for_notetype( + &self, + ntid: NoteTypeID, + ) -> Result> { + self.db + .prepare_cached(concat!( + include_str!("existing_cards.sql"), + " where c.nid in (select id from notes where mid=?)" + ))? + .query_and_then(&[ntid], row_to_existing_card)? + .collect() + } + + pub(crate) fn existing_cards_for_note( + &self, + nid: NoteID, + ) -> Result> { + self.db + .prepare_cached(concat!( + include_str!("existing_cards.sql"), + " where c.nid = ?" + ))? + .query_and_then(&[nid], row_to_existing_card)? + .collect() + } + // Upgrading/downgrading/legacy pub(crate) fn get_all_notetypes_as_schema11(