add notetype changing to backend
This commit is contained in:
parent
c5e56a5fe8
commit
1f2567e04c
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -31,6 +31,7 @@ impl AnkiError {
|
||||
AnkiError::ParseNumError => Kind::InvalidInput,
|
||||
AnkiError::InvalidRegex(_) => Kind::InvalidInput,
|
||||
AnkiError::UndoEmpty => Kind::UndoEmpty,
|
||||
AnkiError::MultipleNotetypesSelected => Kind::InvalidInput,
|
||||
};
|
||||
|
||||
pb::BackendError {
|
||||
|
@ -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(),
|
||||
})
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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
|
||||
|
549
rslib/src/notetype/notetypechange.rs
Normal file
549
rslib/src/notetype/notetypechange.rs
Normal 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(¬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<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(¬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<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(¬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<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(())
|
||||
}
|
||||
}
|
@ -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};
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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)?)))?
|
||||
|
@ -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)]
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user