mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
Deck scoped dupe check (#2372)
* Support limiting dupe check to deck * Expose deck limiting dupe check on frontend * Make CSV dupe options configurable with headers * Rename duplicate file headers * Change dupe check limit to enum
This commit is contained in:
parent
b4290fbe44
commit
5a53da23ca
16 changed files with 275 additions and 40 deletions
|
@ -106,6 +106,8 @@ importing-update = Update
|
||||||
importing-tag-all-notes = Tag all notes
|
importing-tag-all-notes = Tag all notes
|
||||||
importing-tag-updated-notes = Tag updated notes
|
importing-tag-updated-notes = Tag updated notes
|
||||||
importing-file = File
|
importing-file = File
|
||||||
|
importing-match-scope = Match scope
|
||||||
|
importing-notetype-and-deck = Notetype and deck
|
||||||
|
|
||||||
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
|
## NO NEED TO TRANSLATE. This text is no longer used by Anki, and will be removed in the future.
|
||||||
|
|
||||||
|
|
|
@ -122,8 +122,8 @@ message CsvMetadataRequest {
|
||||||
message CsvMetadata {
|
message CsvMetadata {
|
||||||
enum DupeResolution {
|
enum DupeResolution {
|
||||||
UPDATE = 0;
|
UPDATE = 0;
|
||||||
IGNORE = 1;
|
PRESERVE = 1;
|
||||||
ADD = 2;
|
DUPLICATE = 2;
|
||||||
// UPDATE_IF_NEWER = 3;
|
// UPDATE_IF_NEWER = 3;
|
||||||
}
|
}
|
||||||
// Order roughly in ascending expected frequency in note text, because the
|
// Order roughly in ascending expected frequency in note text, because the
|
||||||
|
@ -161,6 +161,10 @@ message CsvMetadata {
|
||||||
// One-based. 0 means n/a.
|
// One-based. 0 means n/a.
|
||||||
uint32 notetype_column = 9;
|
uint32 notetype_column = 9;
|
||||||
}
|
}
|
||||||
|
enum MatchScope {
|
||||||
|
NOTETYPE = 0;
|
||||||
|
NOTETYPE_AND_DECK = 1;
|
||||||
|
}
|
||||||
// One-based. 0 means n/a.
|
// One-based. 0 means n/a.
|
||||||
uint32 tags_column = 10;
|
uint32 tags_column = 10;
|
||||||
bool force_delimiter = 11;
|
bool force_delimiter = 11;
|
||||||
|
@ -168,6 +172,7 @@ message CsvMetadata {
|
||||||
repeated generic.StringList preview = 13;
|
repeated generic.StringList preview = 13;
|
||||||
uint32 guid_column = 14;
|
uint32 guid_column = 14;
|
||||||
DupeResolution dupe_resolution = 15;
|
DupeResolution dupe_resolution = 15;
|
||||||
|
MatchScope match_scope = 16;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ExportCardCsvRequest {
|
message ExportCardCsvRequest {
|
||||||
|
|
|
@ -273,10 +273,10 @@ class LogQueue:
|
||||||
|
|
||||||
|
|
||||||
def first_field_queue(log: ImportLogWithChanges.Log) -> LogQueue:
|
def first_field_queue(log: ImportLogWithChanges.Log) -> LogQueue:
|
||||||
if log.dupe_resolution == DupeResolution.ADD:
|
if log.dupe_resolution == DupeResolution.DUPLICATE:
|
||||||
summary_template = tr.importing_added_duplicate_with_first_field
|
summary_template = tr.importing_added_duplicate_with_first_field
|
||||||
action_string = tr.adding_added()
|
action_string = tr.adding_added()
|
||||||
elif log.dupe_resolution == DupeResolution.IGNORE:
|
elif log.dupe_resolution == DupeResolution.PRESERVE:
|
||||||
summary_template = tr.importing_first_field_matched
|
summary_template = tr.importing_first_field_matched
|
||||||
action_string = tr.importing_skipped()
|
action_string = tr.importing_skipped()
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -118,6 +118,10 @@ pub fn write_backend_proto_rs() {
|
||||||
"CsvMetadata.DupeResolution",
|
"CsvMetadata.DupeResolution",
|
||||||
"#[derive(serde_derive::Deserialize, serde_derive::Serialize)]",
|
"#[derive(serde_derive::Deserialize, serde_derive::Serialize)]",
|
||||||
)
|
)
|
||||||
|
.type_attribute(
|
||||||
|
"CsvMetadata.MatchScope",
|
||||||
|
"#[derive(serde_derive::Deserialize, serde_derive::Serialize)]",
|
||||||
|
)
|
||||||
.compile_protos(paths.as_slice(), &[proto_dir])
|
.compile_protos(paths.as_slice(), &[proto_dir])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,7 @@ use crate::prelude::*;
|
||||||
#[strum(serialize_all = "camelCase")]
|
#[strum(serialize_all = "camelCase")]
|
||||||
pub enum I32ConfigKey {
|
pub enum I32ConfigKey {
|
||||||
CsvDuplicateResolution,
|
CsvDuplicateResolution,
|
||||||
|
MatchScope,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Collection {
|
impl Collection {
|
||||||
|
|
|
@ -28,21 +28,28 @@ impl Collection {
|
||||||
progress_fn: impl 'static + FnMut(ImportProgress, bool) -> bool,
|
progress_fn: impl 'static + FnMut(ImportProgress, bool) -> bool,
|
||||||
) -> Result<OpOutput<NoteLog>> {
|
) -> Result<OpOutput<NoteLog>> {
|
||||||
let file = open_file(path)?;
|
let file = open_file(path)?;
|
||||||
let default_deck = metadata.deck()?.name_or_id();
|
|
||||||
let default_notetype = metadata.notetype()?.name_or_id();
|
|
||||||
let mut ctx = ColumnContext::new(&metadata)?;
|
let mut ctx = ColumnContext::new(&metadata)?;
|
||||||
let notes = ctx.deserialize_csv(file, metadata.delimiter())?;
|
let notes = ctx.deserialize_csv(file, metadata.delimiter())?;
|
||||||
|
let mut data = ForeignData::from(metadata);
|
||||||
|
data.notes = notes;
|
||||||
|
data.import(self, progress_fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<CsvMetadata> for ForeignData {
|
||||||
|
fn from(metadata: CsvMetadata) -> Self {
|
||||||
ForeignData {
|
ForeignData {
|
||||||
dupe_resolution: metadata.dupe_resolution(),
|
dupe_resolution: metadata.dupe_resolution(),
|
||||||
default_deck,
|
match_scope: metadata.match_scope(),
|
||||||
default_notetype,
|
default_deck: metadata.deck().map(|d| d.name_or_id()).unwrap_or_default(),
|
||||||
notes,
|
default_notetype: metadata
|
||||||
|
.notetype()
|
||||||
|
.map(|nt| nt.name_or_id())
|
||||||
|
.unwrap_or_default(),
|
||||||
global_tags: metadata.global_tags,
|
global_tags: metadata.global_tags,
|
||||||
updated_tags: metadata.updated_tags,
|
updated_tags: metadata.updated_tags,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.import(self, progress_fn)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,6 +296,7 @@ mod test {
|
||||||
})),
|
})),
|
||||||
preview: Vec::new(),
|
preview: Vec::new(),
|
||||||
dupe_resolution: 0,
|
dupe_resolution: 0,
|
||||||
|
match_scope: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,6 +23,7 @@ pub use crate::pb::import_export::csv_metadata::Deck as CsvDeck;
|
||||||
pub use crate::pb::import_export::csv_metadata::Delimiter;
|
pub use crate::pb::import_export::csv_metadata::Delimiter;
|
||||||
pub use crate::pb::import_export::csv_metadata::DupeResolution;
|
pub use crate::pb::import_export::csv_metadata::DupeResolution;
|
||||||
pub use crate::pb::import_export::csv_metadata::MappedNotetype;
|
pub use crate::pb::import_export::csv_metadata::MappedNotetype;
|
||||||
|
pub use crate::pb::import_export::csv_metadata::MatchScope;
|
||||||
pub use crate::pb::import_export::csv_metadata::Notetype as CsvNotetype;
|
pub use crate::pb::import_export::csv_metadata::Notetype as CsvNotetype;
|
||||||
pub use crate::pb::import_export::CsvMetadata;
|
pub use crate::pb::import_export::CsvMetadata;
|
||||||
use crate::prelude::*;
|
use crate::prelude::*;
|
||||||
|
@ -54,14 +55,7 @@ impl Collection {
|
||||||
notetype_id: Option<NotetypeId>,
|
notetype_id: Option<NotetypeId>,
|
||||||
is_html: Option<bool>,
|
is_html: Option<bool>,
|
||||||
) -> Result<CsvMetadata> {
|
) -> Result<CsvMetadata> {
|
||||||
let dupe_resolution =
|
let mut metadata = CsvMetadata::from_config(self);
|
||||||
DupeResolution::from_i32(self.get_config_i32(I32ConfigKey::CsvDuplicateResolution))
|
|
||||||
.map(|r| r as i32)
|
|
||||||
.unwrap_or_default();
|
|
||||||
let mut metadata = CsvMetadata {
|
|
||||||
dupe_resolution,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
let meta_len = self.parse_meta_lines(&mut reader, &mut metadata)? as u64;
|
let meta_len = self.parse_meta_lines(&mut reader, &mut metadata)? as u64;
|
||||||
maybe_set_fallback_delimiter(delimiter, &mut metadata, &mut reader, meta_len)?;
|
maybe_set_fallback_delimiter(delimiter, &mut metadata, &mut reader, meta_len)?;
|
||||||
let records = collect_preview_records(&mut metadata, reader)?;
|
let records = collect_preview_records(&mut metadata, reader)?;
|
||||||
|
@ -168,6 +162,16 @@ impl Collection {
|
||||||
metadata.guid_column = n;
|
metadata.guid_column = n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"match scope" => {
|
||||||
|
if let Some(scope) = MatchScope::from_text(value) {
|
||||||
|
metadata.match_scope = scope as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"if matches" => {
|
||||||
|
if let Some(resolution) = DupeResolution::from_text(value) {
|
||||||
|
metadata.dupe_resolution = resolution as i32;
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -244,6 +248,46 @@ impl Collection {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl CsvMetadata {
|
||||||
|
/// Defaults with config values filled in.
|
||||||
|
fn from_config(col: &Collection) -> Self {
|
||||||
|
Self {
|
||||||
|
dupe_resolution: DupeResolution::from_config(col) as i32,
|
||||||
|
match_scope: MatchScope::from_config(col) as i32,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DupeResolution {
|
||||||
|
fn from_config(col: &Collection) -> Self {
|
||||||
|
Self::from_i32(col.get_config_i32(I32ConfigKey::CsvDuplicateResolution)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_text(text: &str) -> Option<Self> {
|
||||||
|
match text {
|
||||||
|
"update current" => Some(Self::Update),
|
||||||
|
"keep current" => Some(Self::Preserve),
|
||||||
|
"keep both" => Some(Self::Duplicate),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MatchScope {
|
||||||
|
fn from_config(col: &Collection) -> Self {
|
||||||
|
Self::from_i32(col.get_config_i32(I32ConfigKey::MatchScope)).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn from_text(text: &str) -> Option<Self> {
|
||||||
|
match text {
|
||||||
|
"notetype" => Some(Self::Notetype),
|
||||||
|
"notetype + deck" => Some(Self::NotetypeAndDeck),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_columns(line: &str, delimiter: Delimiter) -> Result<Vec<String>> {
|
fn parse_columns(line: &str, delimiter: Delimiter) -> Result<Vec<String>> {
|
||||||
map_single_record(line, delimiter, |record| {
|
map_single_record(line, delimiter, |record| {
|
||||||
record.iter().map(ToString::to_string).collect()
|
record.iter().map(ToString::to_string).collect()
|
||||||
|
|
|
@ -16,6 +16,7 @@ use crate::import_export::text::ForeignData;
|
||||||
use crate::import_export::text::ForeignNote;
|
use crate::import_export::text::ForeignNote;
|
||||||
use crate::import_export::text::ForeignNotetype;
|
use crate::import_export::text::ForeignNotetype;
|
||||||
use crate::import_export::text::ForeignTemplate;
|
use crate::import_export::text::ForeignTemplate;
|
||||||
|
use crate::import_export::text::MatchScope;
|
||||||
use crate::import_export::ImportProgress;
|
use crate::import_export::ImportProgress;
|
||||||
use crate::import_export::IncrementableProgress;
|
use crate::import_export::IncrementableProgress;
|
||||||
use crate::import_export::NoteLog;
|
use crate::import_export::NoteLog;
|
||||||
|
@ -37,10 +38,7 @@ impl ForeignData {
|
||||||
let mut progress = IncrementableProgress::new(progress_fn);
|
let mut progress = IncrementableProgress::new(progress_fn);
|
||||||
progress.call(ImportProgress::File)?;
|
progress.call(ImportProgress::File)?;
|
||||||
col.transact(Op::Import, |col| {
|
col.transact(Op::Import, |col| {
|
||||||
col.set_config_i32_inner(
|
self.update_config(col)?;
|
||||||
I32ConfigKey::CsvDuplicateResolution,
|
|
||||||
self.dupe_resolution as i32,
|
|
||||||
)?;
|
|
||||||
let mut ctx = Context::new(&self, col)?;
|
let mut ctx = Context::new(&self, col)?;
|
||||||
ctx.import_foreign_notetypes(self.notetypes)?;
|
ctx.import_foreign_notetypes(self.notetypes)?;
|
||||||
ctx.import_foreign_notes(
|
ctx.import_foreign_notes(
|
||||||
|
@ -51,6 +49,15 @@ impl ForeignData {
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_config(&self, col: &mut Collection) -> Result<()> {
|
||||||
|
col.set_config_i32_inner(
|
||||||
|
I32ConfigKey::CsvDuplicateResolution,
|
||||||
|
self.dupe_resolution as i32,
|
||||||
|
)?;
|
||||||
|
col.set_config_i32_inner(I32ConfigKey::MatchScope, self.match_scope as i32)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl NoteLog {
|
impl NoteLog {
|
||||||
|
@ -73,7 +80,7 @@ struct Context<'a> {
|
||||||
today: u32,
|
today: u32,
|
||||||
dupe_resolution: DupeResolution,
|
dupe_resolution: DupeResolution,
|
||||||
card_gen_ctxs: HashMap<(NotetypeId, DeckId), CardGenContext<Arc<Notetype>>>,
|
card_gen_ctxs: HashMap<(NotetypeId, DeckId), CardGenContext<Arc<Notetype>>>,
|
||||||
existing_checksums: HashMap<(NotetypeId, u32), Vec<NoteId>>,
|
existing_checksums: ExistingChecksums,
|
||||||
existing_guids: HashMap<String, NoteId>,
|
existing_guids: HashMap<String, NoteId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,6 +90,37 @@ struct DeckIdsByNameOrId {
|
||||||
default: Option<DeckId>,
|
default: Option<DeckId>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notes in the collection indexed by notetype, checksum and optionally deck.
|
||||||
|
/// With deck, a note will be included in as many entries as its cards
|
||||||
|
/// have different original decks.
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum ExistingChecksums {
|
||||||
|
ByNotetype(HashMap<(NotetypeId, u32), Vec<NoteId>>),
|
||||||
|
ByNotetypeAndDeck(HashMap<(NotetypeId, u32, DeckId), Vec<NoteId>>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExistingChecksums {
|
||||||
|
fn new(col: &mut Collection, match_scope: MatchScope) -> Result<Self> {
|
||||||
|
match match_scope {
|
||||||
|
MatchScope::Notetype => col
|
||||||
|
.storage
|
||||||
|
.all_notes_by_type_and_checksum()
|
||||||
|
.map(Self::ByNotetype),
|
||||||
|
MatchScope::NotetypeAndDeck => col
|
||||||
|
.storage
|
||||||
|
.all_notes_by_type_checksum_and_deck()
|
||||||
|
.map(Self::ByNotetypeAndDeck),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, notetype: NotetypeId, checksum: u32, deck: DeckId) -> Option<&Vec<NoteId>> {
|
||||||
|
match self {
|
||||||
|
Self::ByNotetype(map) => map.get(&(notetype, checksum)),
|
||||||
|
Self::ByNotetypeAndDeck(map) => map.get(&(notetype, checksum, deck)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct NoteContext<'a> {
|
struct NoteContext<'a> {
|
||||||
note: ForeignNote,
|
note: ForeignNote,
|
||||||
dupes: Vec<Duplicate>,
|
dupes: Vec<Duplicate>,
|
||||||
|
@ -152,7 +190,7 @@ impl<'a> Context<'a> {
|
||||||
col.notetype_by_name_or_id(&data.default_notetype)?,
|
col.notetype_by_name_or_id(&data.default_notetype)?,
|
||||||
);
|
);
|
||||||
let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck)?;
|
let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck)?;
|
||||||
let existing_checksums = col.storage.all_notes_by_type_and_checksum()?;
|
let existing_checksums = ExistingChecksums::new(col, data.match_scope)?;
|
||||||
let existing_guids = col.storage.all_notes_by_guid()?;
|
let existing_guids = col.storage.all_notes_by_guid()?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
@ -247,7 +285,7 @@ impl<'a> Context<'a> {
|
||||||
updated_tags: &'tags [String],
|
updated_tags: &'tags [String],
|
||||||
) -> Result<NoteContext<'tags>> {
|
) -> Result<NoteContext<'tags>> {
|
||||||
self.prepare_foreign_note(&mut note)?;
|
self.prepare_foreign_note(&mut note)?;
|
||||||
let dupes = self.find_duplicates(¬etype, ¬e)?;
|
let dupes = self.find_duplicates(¬etype, ¬e, deck_id)?;
|
||||||
Ok(NoteContext {
|
Ok(NoteContext {
|
||||||
note,
|
note,
|
||||||
dupes,
|
dupes,
|
||||||
|
@ -263,11 +301,16 @@ impl<'a> Context<'a> {
|
||||||
self.col.canonify_foreign_tags(note, self.usn)
|
self.col.canonify_foreign_tags(note, self.usn)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_duplicates(&self, notetype: &Notetype, note: &ForeignNote) -> Result<Vec<Duplicate>> {
|
fn find_duplicates(
|
||||||
|
&self,
|
||||||
|
notetype: &Notetype,
|
||||||
|
note: &ForeignNote,
|
||||||
|
deck_id: DeckId,
|
||||||
|
) -> Result<Vec<Duplicate>> {
|
||||||
if note.guid.is_empty() {
|
if note.guid.is_empty() {
|
||||||
if let Some(nids) = note
|
if let Some(nids) = note
|
||||||
.checksum()
|
.checksum()
|
||||||
.and_then(|csum| self.existing_checksums.get(&(notetype.id, csum)))
|
.and_then(|csum| self.existing_checksums.get(notetype.id, csum, deck_id))
|
||||||
{
|
{
|
||||||
return self.get_first_field_dupes(note, nids);
|
return self.get_first_field_dupes(note, nids);
|
||||||
}
|
}
|
||||||
|
@ -297,15 +340,15 @@ impl<'a> Context<'a> {
|
||||||
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
||||||
match self.dupe_resolution {
|
match self.dupe_resolution {
|
||||||
_ if !ctx.is_dupe() => self.add_note(ctx, log)?,
|
_ if !ctx.is_dupe() => self.add_note(ctx, log)?,
|
||||||
DupeResolution::Add if ctx.is_guid_dupe() => {
|
DupeResolution::Duplicate if ctx.is_guid_dupe() => {
|
||||||
log.duplicate.push(ctx.note.into_log_note())
|
log.duplicate.push(ctx.note.into_log_note())
|
||||||
}
|
}
|
||||||
DupeResolution::Add if !ctx.has_first_field() => {
|
DupeResolution::Duplicate if !ctx.has_first_field() => {
|
||||||
log.empty_first_field.push(ctx.note.into_log_note())
|
log.empty_first_field.push(ctx.note.into_log_note())
|
||||||
}
|
}
|
||||||
DupeResolution::Add => self.add_note(ctx, log)?,
|
DupeResolution::Duplicate => self.add_note(ctx, log)?,
|
||||||
DupeResolution::Update => self.update_with_note(ctx, log)?,
|
DupeResolution::Update => self.update_with_note(ctx, log)?,
|
||||||
DupeResolution::Ignore => log.first_field_match.push(ctx.note.into_log_note()),
|
DupeResolution::Preserve => log.first_field_match.push(ctx.note.into_log_note()),
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
@ -588,6 +631,8 @@ impl ForeignTemplate {
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::collection::open_test_collection;
|
use crate::collection::open_test_collection;
|
||||||
|
use crate::tests::DeckAdder;
|
||||||
|
use crate::tests::NoteAdder;
|
||||||
|
|
||||||
impl ForeignData {
|
impl ForeignData {
|
||||||
fn with_defaults() -> Self {
|
fn with_defaults() -> Self {
|
||||||
|
@ -611,7 +656,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["same", "old"]);
|
data.add_note(&["same", "old"]);
|
||||||
data.dupe_resolution = DupeResolution::Add;
|
data.dupe_resolution = DupeResolution::Duplicate;
|
||||||
|
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
@ -623,7 +668,7 @@ mod test {
|
||||||
let mut col = open_test_collection();
|
let mut col = open_test_collection();
|
||||||
let mut data = ForeignData::with_defaults();
|
let mut data = ForeignData::with_defaults();
|
||||||
data.add_note(&["same", "old"]);
|
data.add_note(&["same", "old"]);
|
||||||
data.dupe_resolution = DupeResolution::Ignore;
|
data.dupe_resolution = DupeResolution::Preserve;
|
||||||
|
|
||||||
data.clone().import(&mut col, |_, _| true).unwrap();
|
data.clone().import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.notes_table_len(), 1);
|
assert_eq!(col.storage.notes_table_len(), 1);
|
||||||
|
@ -711,4 +756,27 @@ mod test {
|
||||||
data.import(&mut col, |_, _| true).unwrap();
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
assert_eq!(col.storage.get_all_notes()[0].tags, ["bar", "baz"]);
|
assert_eq!(col.storage.get_all_notes()[0].tags, ["bar", "baz"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_only_update_duplicates_in_same_deck_if_limit_is_enabled() {
|
||||||
|
let mut col = open_test_collection();
|
||||||
|
let other_deck_id = DeckAdder::new("other").add(&mut col).id;
|
||||||
|
NoteAdder::basic(&mut col)
|
||||||
|
.fields(&["foo", "old"])
|
||||||
|
.add(&mut col);
|
||||||
|
NoteAdder::basic(&mut col)
|
||||||
|
.fields(&["foo", "old"])
|
||||||
|
.deck(other_deck_id)
|
||||||
|
.add(&mut col);
|
||||||
|
let mut data = ForeignData::with_defaults();
|
||||||
|
data.match_scope = MatchScope::NotetypeAndDeck;
|
||||||
|
data.add_note(&["foo", "new"]);
|
||||||
|
|
||||||
|
data.import(&mut col, |_, _| true).unwrap();
|
||||||
|
let notes = col.storage.get_all_notes();
|
||||||
|
// same deck, should be updated
|
||||||
|
assert_eq!(notes[0].fields()[1], "new");
|
||||||
|
// other deck, should be unchanged
|
||||||
|
assert_eq!(notes[1].fields()[1], "old");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,11 +10,13 @@ use serde_derive::Serialize;
|
||||||
|
|
||||||
use super::LogNote;
|
use super::LogNote;
|
||||||
use crate::pb::import_export::csv_metadata::DupeResolution;
|
use crate::pb::import_export::csv_metadata::DupeResolution;
|
||||||
|
use crate::pb::import_export::csv_metadata::MatchScope;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub struct ForeignData {
|
pub struct ForeignData {
|
||||||
dupe_resolution: DupeResolution,
|
dupe_resolution: DupeResolution,
|
||||||
|
match_scope: MatchScope,
|
||||||
default_deck: NameOrId,
|
default_deck: NameOrId,
|
||||||
default_notetype: NameOrId,
|
default_notetype: NameOrId,
|
||||||
notes: Vec<ForeignNote>,
|
notes: Vec<ForeignNote>,
|
||||||
|
|
|
@ -7,15 +7,11 @@ use std::collections::HashSet;
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use rusqlite::Row;
|
use rusqlite::Row;
|
||||||
|
|
||||||
use crate::error::Result;
|
|
||||||
use crate::import_export::package::NoteMeta;
|
use crate::import_export::package::NoteMeta;
|
||||||
use crate::notes::Note;
|
|
||||||
use crate::notes::NoteId;
|
|
||||||
use crate::notes::NoteTags;
|
use crate::notes::NoteTags;
|
||||||
use crate::notetype::NotetypeId;
|
use crate::prelude::*;
|
||||||
use crate::tags::join_tags;
|
use crate::tags::join_tags;
|
||||||
use crate::tags::split_tags;
|
use crate::tags::split_tags;
|
||||||
use crate::timestamp::TimestampMillis;
|
|
||||||
|
|
||||||
pub(crate) fn split_fields(fields: &str) -> Vec<String> {
|
pub(crate) fn split_fields(fields: &str) -> Vec<String> {
|
||||||
fields.split('\x1f').map(Into::into).collect()
|
fields.split('\x1f').map(Into::into).collect()
|
||||||
|
@ -195,6 +191,22 @@ impl super::SqliteStorage {
|
||||||
Ok(map)
|
Ok(map)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn all_notes_by_type_checksum_and_deck(
|
||||||
|
&self,
|
||||||
|
) -> Result<HashMap<(NotetypeId, u32, DeckId), Vec<NoteId>>> {
|
||||||
|
let mut map = HashMap::new();
|
||||||
|
let mut stmt = self
|
||||||
|
.db
|
||||||
|
.prepare(include_str!("notes_types_checksums_decks.sql"))?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
map.entry((row.get(1)?, row.get(2)?, row.get(3)?))
|
||||||
|
.or_insert_with(Vec::new)
|
||||||
|
.push(row.get(0)?);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
}
|
||||||
|
|
||||||
/// Return total number of notes. Slow.
|
/// Return total number of notes. Slow.
|
||||||
pub(crate) fn total_notes(&self) -> Result<u32> {
|
pub(crate) fn total_notes(&self) -> Result<u32> {
|
||||||
self.db
|
self.db
|
||||||
|
|
9
rslib/src/storage/note/notes_types_checksums_decks.sql
Normal file
9
rslib/src/storage/note/notes_types_checksums_decks.sql
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
SELECT DISTINCT notes.id,
|
||||||
|
notes.mid,
|
||||||
|
notes.csum,
|
||||||
|
CASE
|
||||||
|
WHEN cards.odid = 0 THEN cards.did
|
||||||
|
ELSE cards.odid
|
||||||
|
END AS did
|
||||||
|
FROM notes
|
||||||
|
JOIN cards ON notes.id = cards.nid
|
|
@ -173,3 +173,37 @@ impl DeckAdder {
|
||||||
deck
|
deck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct NoteAdder {
|
||||||
|
note: Note,
|
||||||
|
deck: DeckId,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NoteAdder {
|
||||||
|
pub(crate) fn new(col: &mut Collection, notetype: &str) -> Self {
|
||||||
|
Self {
|
||||||
|
note: col.new_note(notetype),
|
||||||
|
deck: DeckId(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn basic(col: &mut Collection) -> Self {
|
||||||
|
Self::new(col, "basic")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fields(mut self, fields: &[&str]) -> Self {
|
||||||
|
*self.note.fields_mut() = fields.iter().map(ToString::to_string).collect();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn deck(mut self, deck: DeckId) -> Self {
|
||||||
|
self.deck = deck;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn add(mut self, col: &mut Collection) -> Note {
|
||||||
|
col.add_note(&mut self.note, self.deck).unwrap();
|
||||||
|
self.note
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
41
ts/import-csv/DeckDupeCheckSwitch.svelte
Normal file
41
ts/import-csv/DeckDupeCheckSwitch.svelte
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import * as tr from "@tslib/ftl";
|
||||||
|
import { ImportExport } from "@tslib/proto";
|
||||||
|
|
||||||
|
import Col from "../components/Col.svelte";
|
||||||
|
import Row from "../components/Row.svelte";
|
||||||
|
import Select from "../components/Select.svelte";
|
||||||
|
import SelectOption from "../components/SelectOption.svelte";
|
||||||
|
|
||||||
|
export let matchScope: ImportExport.CsvMetadata.MatchScope;
|
||||||
|
|
||||||
|
const matchScopes = [
|
||||||
|
{
|
||||||
|
value: ImportExport.CsvMetadata.MatchScope.NOTETYPE,
|
||||||
|
label: tr.notetypesNotetype(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ImportExport.CsvMetadata.MatchScope.NOTETYPE_AND_DECK,
|
||||||
|
label: tr.importingNotetypeAndDeck(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
$: label = matchScopes.find((r) => r.value === matchScope)?.label;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Row --cols={2}>
|
||||||
|
<Col --col-size={1}>
|
||||||
|
{tr.importingMatchScope()}
|
||||||
|
</Col>
|
||||||
|
<Col --col-size={1}>
|
||||||
|
<Select bind:value={matchScope} {label}>
|
||||||
|
{#each matchScopes as { label, value }}
|
||||||
|
<SelectOption {value}>{label}</SelectOption>
|
||||||
|
{/each}
|
||||||
|
</Select>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
|
@ -19,11 +19,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
label: tr.importingUpdate(),
|
label: tr.importingUpdate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ImportExport.CsvMetadata.DupeResolution.ADD,
|
value: ImportExport.CsvMetadata.DupeResolution.DUPLICATE,
|
||||||
label: tr.importingDuplicate(),
|
label: tr.importingDuplicate(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ImportExport.CsvMetadata.DupeResolution.IGNORE,
|
value: ImportExport.CsvMetadata.DupeResolution.PRESERVE,
|
||||||
label: tr.importingPreserve(),
|
label: tr.importingPreserve(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import Container from "../components/Container.svelte";
|
import Container from "../components/Container.svelte";
|
||||||
import Row from "../components/Row.svelte";
|
import Row from "../components/Row.svelte";
|
||||||
import Spacer from "../components/Spacer.svelte";
|
import Spacer from "../components/Spacer.svelte";
|
||||||
|
import DeckDupeCheckSwitch from "./DeckDupeCheckSwitch.svelte";
|
||||||
import DeckSelector from "./DeckSelector.svelte";
|
import DeckSelector from "./DeckSelector.svelte";
|
||||||
import DelimiterSelector from "./DelimiterSelector.svelte";
|
import DelimiterSelector from "./DelimiterSelector.svelte";
|
||||||
import DupeResolutionSelector from "./DupeResolutionSelector.svelte";
|
import DupeResolutionSelector from "./DupeResolutionSelector.svelte";
|
||||||
|
@ -27,6 +28,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
export let notetypeNameIds: Notetypes.NotetypeNameId[];
|
export let notetypeNameIds: Notetypes.NotetypeNameId[];
|
||||||
export let deckNameIds: Decks.DeckNameId[];
|
export let deckNameIds: Decks.DeckNameId[];
|
||||||
export let dupeResolution: ImportExport.CsvMetadata.DupeResolution;
|
export let dupeResolution: ImportExport.CsvMetadata.DupeResolution;
|
||||||
|
export let matchScope: ImportExport.CsvMetadata.MatchScope;
|
||||||
|
|
||||||
export let delimiter: ImportExport.CsvMetadata.Delimiter;
|
export let delimiter: ImportExport.CsvMetadata.Delimiter;
|
||||||
export let forceDelimiter: boolean;
|
export let forceDelimiter: boolean;
|
||||||
|
@ -73,6 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
path,
|
path,
|
||||||
metadata: ImportExport.CsvMetadata.create({
|
metadata: ImportExport.CsvMetadata.create({
|
||||||
dupeResolution,
|
dupeResolution,
|
||||||
|
matchScope,
|
||||||
delimiter,
|
delimiter,
|
||||||
forceDelimiter,
|
forceDelimiter,
|
||||||
isHtml,
|
isHtml,
|
||||||
|
@ -119,6 +122,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<DeckSelector {deckNameIds} bind:deckId />
|
<DeckSelector {deckNameIds} bind:deckId />
|
||||||
{/if}
|
{/if}
|
||||||
<DupeResolutionSelector bind:dupeResolution />
|
<DupeResolutionSelector bind:dupeResolution />
|
||||||
|
<DeckDupeCheckSwitch bind:matchScope />
|
||||||
<Tags bind:globalTags bind:updatedTags />
|
<Tags bind:globalTags bind:updatedTags />
|
||||||
</Container>
|
</Container>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
|
@ -48,6 +48,7 @@ export async function setupImportCsvPage(path: string): Promise<ImportCsvPage> {
|
||||||
deckNameIds: decks.entries,
|
deckNameIds: decks.entries,
|
||||||
notetypeNameIds: notetypes.entries,
|
notetypeNameIds: notetypes.entries,
|
||||||
dupeResolution: metadata.dupeResolution,
|
dupeResolution: metadata.dupeResolution,
|
||||||
|
matchScope: metadata.matchScope,
|
||||||
delimiter: metadata.delimiter,
|
delimiter: metadata.delimiter,
|
||||||
forceDelimiter: metadata.forceDelimiter,
|
forceDelimiter: metadata.forceDelimiter,
|
||||||
isHtml: metadata.isHtml,
|
isHtml: metadata.isHtml,
|
||||||
|
|
Loading…
Reference in a new issue