add notetype changing to backend

This commit is contained in:
Damien Elmes 2021-06-09 20:56:52 +10:00
parent c5e56a5fe8
commit 1f2567e04c
16 changed files with 731 additions and 13 deletions

View File

@ -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

View File

@ -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.

View File

@ -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;
}

View File

@ -31,6 +31,7 @@ impl AnkiError {
AnkiError::ParseNumError => Kind::InvalidInput,
AnkiError::InvalidRegex(_) => Kind::InvalidInput,
AnkiError::UndoEmpty => Kind::UndoEmpty,
AnkiError::MultipleNotetypesSelected => Kind::InvalidInput,
};
pb::BackendError {

View File

@ -131,7 +131,7 @@ impl NotesService for Backend {
fn cards_of_note(&self, input: pb::NoteId) -> Result<pb::CardIds> {
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(),
})

View File

@ -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<pb::ChangeNotetypeInfo> {
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<pb::OpChanges> {
self.with_col(|col| col.change_notetype_of_notes(input.into()).map(Into::into))
}
}
impl From<pb::Notetype> for Notetype {
@ -168,3 +183,69 @@ impl From<pb::Notetype> for Notetype {
}
}
}
impl From<NotetypeChangeInfo> 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<pb::ChangeNotetypeIn> 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<ChangeNotetypeInput> 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(),
}
}
}

View File

@ -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!("<pre>{}</pre>", 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),
}
}
}

View File

@ -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);
}

View File

@ -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<OpOutput<()>> {
self.transact(Op::UpdateNotetype, |col| {
let original = col

View File

@ -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<NoteId>,
pub old_notetype_id: NotetypeId,
pub new_notetype_id: NotetypeId,
pub new_fields: Vec<Option<usize>>,
pub new_templates: Option<Vec<Option<usize>>>,
}
#[derive(Debug)]
pub struct NotetypeChangeInfo {
pub input: ChangeNotetypeInput,
pub old_field_names: Vec<String>,
pub old_template_names: Vec<String>,
pub new_field_names: Vec<String>,
pub new_template_names: Vec<String>,
}
#[derive(Debug, PartialEq)]
pub struct TemplateMap {
pub removed: Vec<usize>,
pub remapped: HashMap<usize, usize>,
}
impl TemplateMap {
fn new(new_templates: Vec<Option<usize>>, old_template_count: usize) -> Result<Self> {
let mut seen: HashSet<usize> = 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<NoteId>,
new_notetype_id: NotetypeId,
) -> Result<NotetypeChangeInfo> {
let old_notetype_id = self.get_single_notetype_of_notes(&note_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<OpOutput<()>> {
self.transact(Op::ChangeNotetype, |col| {
col.change_notetype_of_notes_inner(input)
})
}
}
fn default_template_map(
current_notetype: &Notetype,
new_notetype: &Notetype,
) -> Option<Vec<Option<usize>>> {
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<Option<usize>> {
// 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<NotetypeId> {
if note_ids.is_empty() {
return Err(AnkiError::NotFound);
}
let nids_node: Node = SearchNode::NoteIds(comma_separated_ids(&note_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<usize>],
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(&notetype, 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<Option<usize>>,
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<String>, new_fields: &[Option<usize>]) {
*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(())
}
}

View File

@ -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};

View File

@ -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()
}

View File

@ -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();

View File

@ -344,7 +344,25 @@ impl super::SqliteStorage {
.collect()
}
pub(crate) fn all_card_ids_of_note_in_order(&self, nid: NoteId) -> Result<Vec<CardId>> {
pub(crate) fn all_cards_of_notes_above_ordinal(
&mut self,
note_ids: &[NoteId],
ordinal: usize,
) -> Result<Vec<Card>> {
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<Vec<CardId>> {
self.db
.prepare_cached("select id from cards where nid = ? order by ord")?
.query_and_then(&[nid], |r| Ok(CardId(r.get(0)?)))?

View File

@ -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<T>(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<T>(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<T>(ids: &[T]) -> String
where
T: std::fmt::Display,
{
let mut buf = String::new();
write_comma_separated_ids(&mut buf, ids);
buf
}
#[cfg(test)]

View File

@ -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