diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0a7f33b8e..bb8d5bf03 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -78,6 +78,7 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError { AnkiError::SchemaChange => V::InvalidInput(pb::Empty {}), AnkiError::JSONError { info } => V::JsonError(info), AnkiError::ProtoError { info } => V::ProtoError(info), + AnkiError::NoCardsGenerated => todo!(), }; pb::BackendError { diff --git a/rslib/src/card.rs b/rslib/src/card.rs index c9ee0467f..6cc5c02ac 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -99,6 +99,15 @@ impl Undoable for UpdateCardUndo { } } +impl Card { + pub fn new(nid: NoteID, ord: u16, deck_id: DeckID) -> Self { + let mut card = Card::default(); + card.nid = nid; + card.ord = ord; + card.did = deck_id; + card + } +} impl Collection { #[cfg(test)] pub(crate) fn get_and_update_card(&mut self, cid: CardID, func: F) -> Result @@ -127,7 +136,6 @@ impl Collection { self.storage.update_card(card) } - #[allow(dead_code)] pub(crate) fn add_card(&mut self, card: &mut Card) -> Result<()> { if card.id.0 != 0 { return Err(AnkiError::invalid_input("card id already set")); diff --git a/rslib/src/err.rs b/rslib/src/err.rs index d3d38c920..cee41d4c7 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -8,7 +8,7 @@ use std::io; pub type Result = std::result::Result; -#[derive(Debug, Fail)] +#[derive(Debug, Fail, PartialEq)] pub enum AnkiError { #[fail(display = "invalid input: {}", info)] InvalidInput { info: String }, @@ -48,6 +48,9 @@ pub enum AnkiError { #[fail(display = "Operation modifies schema, but schema not marked modified.")] SchemaChange, + + #[fail(display = "No cards generated.")] + NoCardsGenerated, } // error helpers diff --git a/rslib/src/media/check.rs b/rslib/src/media/check.rs index 32b987b4a..58c91f045 100644 --- a/rslib/src/media/check.rs +++ b/rslib/src/media/check.rs @@ -404,7 +404,7 @@ where &self.mgr.media_folder, )? { // note was modified, needs saving - note.prepare_for_update(nt.config.sort_field_idx as usize, usn); + note.prepare_for_update(nt, usn)?; self.ctx.storage.update_note(¬e)?; collection_modified = true; } diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index b40537ffa..b28c585e8 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -1,11 +1,16 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::err::{AnkiError, Result}; -use crate::notetype::NoteTypeID; -use crate::text::strip_html_preserving_image_filenames; -use crate::timestamp::TimestampSecs; -use crate::{collection::Collection, define_newtype, types::Usn}; +use crate::{ + card::Card, + collection::Collection, + define_newtype, + err::{AnkiError, Result}, + notetype::{CardGenContext, NoteField, NoteType, NoteTypeID}, + text::strip_html_preserving_image_filenames, + timestamp::TimestampSecs, + types::Usn, +}; use num_integer::Integer; use std::{collections::HashSet, convert::TryInto}; @@ -27,15 +32,15 @@ pub struct Note { } impl Note { - pub(crate) fn new(ntid: NoteTypeID, field_count: usize) -> Self { + pub(crate) fn new(notetype: &NoteType) -> Self { Note { id: NoteID(0), guid: guid(), - ntid, + ntid: notetype.id, mtime: TimestampSecs(0), usn: Usn(0), tags: vec![], - fields: vec!["".to_string(); field_count], + fields: vec!["".to_string(); notetype.fields.len()], sort_field: None, checksum: None, } @@ -57,15 +62,24 @@ impl Note { Ok(()) } - pub fn prepare_for_update(&mut self, sort_field_idx: usize, usn: Usn) { + pub fn prepare_for_update(&mut self, nt: &NoteType, usn: Usn) -> Result<()> { + assert!(nt.id == self.ntid); + if nt.fields.len() != self.fields.len() { + return Err(AnkiError::invalid_input(format!( + "note has {} fields, expected {}", + self.fields.len(), + nt.fields.len() + ))); + } + let field1_nohtml = strip_html_preserving_image_filenames(&self.fields()[0]); let checksum = field_checksum(field1_nohtml.as_ref()); - let sort_field = if sort_field_idx == 0 { + let sort_field = if nt.config.sort_field_idx == 0 { field1_nohtml } else { strip_html_preserving_image_filenames( self.fields - .get(sort_field_idx) + .get(nt.config.sort_field_idx as usize) .map(AsRef::as_ref) .unwrap_or(""), ) @@ -74,10 +88,10 @@ impl Note { self.checksum = Some(checksum); self.mtime = TimestampSecs::now(); self.usn = usn; + Ok(()) } - #[allow(dead_code)] - pub(crate) fn nonempty_fields(&self) -> HashSet { + pub(crate) fn nonempty_fields<'a>(&self, fields: &'a [NoteField]) -> HashSet<&'a str> { self.fields .iter() .enumerate() @@ -85,7 +99,7 @@ impl Note { if s.trim().is_empty() { None } else { - Some(ord as u16) + fields.get(ord).map(|f| f.name.as_str()) } }) .collect() @@ -105,7 +119,7 @@ fn guid() -> String { fn anki_base91(mut n: u64) -> String { let table = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ\ - 0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~"; +0123456789!#$%&()*+,-./:;<=>?@[]^_`{|}~"; let mut buf = String::new(); while n > 0 { let (q, r) = n.div_rem(&(table.len() as u64)); @@ -119,17 +133,50 @@ fn anki_base91(mut n: u64) -> String { impl Collection { pub fn add_note(&mut self, note: &mut Note) -> Result<()> { self.transact(None, |col| { - println!("fixme: need to add cards, abort if no cards generated, etc"); - // fixme: proper index - note.prepare_for_update(0, col.usn()?); - col.storage.add_note(note) + let nt = col + .storage + .get_full_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) }) } + + 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()); + if cards.is_empty() { + return Err(AnkiError::NoCardsGenerated); + } + + // add the note + 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)?; + } + + Ok(()) + } } #[cfg(test)] mod test { use super::{anki_base91, field_checksum}; + use crate::{ + collection::open_test_collection, + err::{AnkiError, Result}, + search::SortMode, + }; #[test] fn test_base91() { @@ -145,4 +192,29 @@ mod test { assert_eq!(field_checksum("test"), 2840236005); assert_eq!(field_checksum("今日"), 1464653051); } + + #[test] + fn adding() -> Result<()> { + let mut col = open_test_collection(); + let nt = col.get_notetype_by_name("basic")?.unwrap(); + + let mut note = nt.new_note(); + assert_eq!(col.add_note(&mut note), Err(AnkiError::NoCardsGenerated)); + + note.fields[1] = "foo".into(); + assert_eq!(col.add_note(&mut note), Err(AnkiError::NoCardsGenerated)); + + note.fields[0] = "bar".into(); + col.add_note(&mut note).unwrap(); + + assert_eq!( + col.search_cards(&format!("nid:{}", note.id), SortMode::NoOrder) + .unwrap() + .len(), + 1 + ); + + // fixme: add nt cache, refcount + Ok(()) + } } diff --git a/rslib/src/notetype/cardgeninfo.rs b/rslib/src/notetype/cardgeninfo.rs new file mode 100644 index 000000000..2b558c2ac --- /dev/null +++ b/rslib/src/notetype/cardgeninfo.rs @@ -0,0 +1,85 @@ +// 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/fields.rs b/rslib/src/notetype/fields.rs index 051d97576..e2b8cd05e 100644 --- a/rslib/src/notetype/fields.rs +++ b/rslib/src/notetype/fields.rs @@ -3,6 +3,7 @@ use crate::backend_proto::{NoteField as NoteFieldProto, NoteFieldConfig, OptionalUInt32}; +#[derive(Debug)] pub struct NoteField { pub ord: Option, pub name: String, diff --git a/rslib/src/notetype/mod.rs b/rslib/src/notetype/mod.rs index 7b0254b5d..6d1ff0fd8 100644 --- a/rslib/src/notetype/mod.rs +++ b/rslib/src/notetype/mod.rs @@ -1,6 +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 fields; mod schema11; mod schemachange; @@ -11,6 +12,7 @@ pub use crate::backend_proto::{ card_requirement::CardRequirementKind, CardRequirement, CardTemplateConfig, NoteFieldConfig, NoteType as NoteTypeProto, NoteTypeConfig, NoteTypeKind, }; +pub(crate) use cardgeninfo::CardGenContext; pub use fields::NoteField; pub use schema11::{CardTemplateSchema11, NoteFieldSchema11, NoteTypeSchema11}; pub use stock::all_stock_notetypes; @@ -18,6 +20,7 @@ pub use templates::CardTemplate; use crate::{ collection::Collection, + decks::DeckID, define_newtype, err::{AnkiError, Result}, notes::Note, @@ -35,6 +38,7 @@ pub(crate) const DEFAULT_CSS: &str = include_str!("styling.css"); pub(crate) const DEFAULT_LATEX_HEADER: &str = include_str!("header.tex"); pub(crate) const DEFAULT_LATEX_FOOTER: &str = r"\end{document}"; +#[derive(Debug)] pub struct NoteType { pub id: NoteTypeID, pub name: String, @@ -165,7 +169,11 @@ impl NoteType { } pub fn new_note(&self) -> Note { - Note::new(self.id, self.fields.len()) + Note::new(&self) + } + + pub fn target_deck_id(&self) -> DeckID { + DeckID(self.config.target_deck_id) } } @@ -194,4 +202,8 @@ impl Collection { Ok(()) }) } + + pub fn get_notetype_by_name(&mut self, name: &str) -> Result> { + self.storage.get_notetype_by_name(name) + } } diff --git a/rslib/src/notetype/schemachange.rs b/rslib/src/notetype/schemachange.rs index ca8f8f5f7..6f6dce076 100644 --- a/rslib/src/notetype/schemachange.rs +++ b/rslib/src/notetype/schemachange.rs @@ -53,7 +53,7 @@ impl Collection { }) .map(Into::into) .collect(); - note.prepare_for_update(nt.config.sort_field_idx as usize, usn); + note.prepare_for_update(nt, usn)?; self.storage.update_note(¬e)?; } Ok(()) diff --git a/rslib/src/notetype/templates.rs b/rslib/src/notetype/templates.rs index 954360264..9ca5186d5 100644 --- a/rslib/src/notetype/templates.rs +++ b/rslib/src/notetype/templates.rs @@ -3,10 +3,13 @@ use crate::{ backend_proto::{CardTemplate as CardTemplateProto, CardTemplateConfig, OptionalUInt32}, + decks::DeckID, + template::ParsedTemplate, timestamp::TimestampSecs, types::Usn, }; +#[derive(Debug)] pub struct CardTemplate { pub ord: Option, pub mtime_secs: TimestampSecs, @@ -15,6 +18,20 @@ pub struct CardTemplate { pub config: CardTemplateConfig, } +impl CardTemplate { + pub(crate) fn parsed_question(&self) -> Option> { + ParsedTemplate::from_text(&self.config.q_format).ok() + } + + pub(crate) fn target_deck_id(&self) -> Option { + if self.config.target_deck_id > 0 { + Some(DeckID(self.config.target_deck_id)) + } else { + None + } + } +} + impl From for CardTemplateProto { fn from(t: CardTemplate) -> Self { CardTemplateProto { diff --git a/rslib/src/storage/notetype/mod.rs b/rslib/src/storage/notetype/mod.rs index 7551e904c..12ac3f48d 100644 --- a/rslib/src/storage/notetype/mod.rs +++ b/rslib/src/storage/notetype/mod.rs @@ -9,7 +9,7 @@ use crate::{ timestamp::TimestampMillis, }; use prost::Message; -use rusqlite::{params, Row, NO_PARAMS}; +use rusqlite::{params, OptionalExtension, Row, NO_PARAMS}; use std::collections::{HashMap, HashSet}; use unicase::UniCase; @@ -36,11 +36,16 @@ impl SqliteStorage { } pub(crate) fn get_all_notetype_core(&self) -> Result> { - self.db + let mut nts: HashMap = self + .db .prepare_cached(include_str!("get_notetype.sql"))? .query_and_then(NO_PARAMS, row_to_notetype_core)? .map(|ntres| ntres.map(|nt| (nt.id, nt))) - .collect() + .collect::>()?; + for nt in nts.values_mut() { + nt.fields = self.get_notetype_fields(nt.id)?; + } + Ok(nts) } pub(crate) fn get_notetype_fields(&self, ntid: NoteTypeID) -> Result> { @@ -84,6 +89,22 @@ impl SqliteStorage { } } + pub(crate) fn get_notetype_id(&self, name: &str) -> Result> { + self.db + .prepare_cached("select id from notetypes where name = ?")? + .query_row(params![name], |row| row.get(0)) + .optional() + .map_err(Into::into) + } + + pub fn get_notetype_by_name(&mut self, name: &str) -> Result> { + if let Some(id) = self.get_notetype_id(name)? { + self.get_full_notetype(id) + } else { + Ok(None) + } + } + #[allow(dead_code)] fn get_all_notetype_names(&self) -> Result> { self.db