mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
857 lines
28 KiB
Rust
857 lines
28 KiB
Rust
// Copyright: Ankitects Pty Ltd and contributors
|
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|
|
|
use std::borrow::Cow;
|
|
use std::collections::HashMap;
|
|
use std::collections::HashSet;
|
|
use std::sync::Arc;
|
|
|
|
use unicase::UniCase;
|
|
|
|
use super::NameOrId;
|
|
use crate::card::CardQueue;
|
|
use crate::card::CardType;
|
|
use crate::config::I32ConfigKey;
|
|
use crate::import_export::text::DupeResolution;
|
|
use crate::import_export::text::ForeignCard;
|
|
use crate::import_export::text::ForeignData;
|
|
use crate::import_export::text::ForeignNote;
|
|
use crate::import_export::text::ForeignNotetype;
|
|
use crate::import_export::text::ForeignTemplate;
|
|
use crate::import_export::text::MatchScope;
|
|
use crate::import_export::ImportProgress;
|
|
use crate::import_export::NoteLog;
|
|
use crate::notes::field_checksum;
|
|
use crate::notes::normalize_field;
|
|
use crate::notetype::CardGenContext;
|
|
use crate::notetype::CardTemplate;
|
|
use crate::notetype::NoteField;
|
|
use crate::prelude::*;
|
|
use crate::progress::ThrottlingProgressHandler;
|
|
use crate::scheduler::timing::SchedTimingToday;
|
|
use crate::text::strip_html_preserving_media_filenames;
|
|
|
|
impl ForeignData {
|
|
pub fn import(
|
|
self,
|
|
col: &mut Collection,
|
|
mut progress: ThrottlingProgressHandler<ImportProgress>,
|
|
) -> Result<OpOutput<NoteLog>> {
|
|
progress.set(ImportProgress::File)?;
|
|
col.transact(Op::Import, |col| {
|
|
self.update_config(col)?;
|
|
let mut ctx = Context::new(&self, col)?;
|
|
ctx.import_foreign_notetypes(self.notetypes)?;
|
|
ctx.import_foreign_notes(
|
|
self.notes,
|
|
&self.global_tags,
|
|
&self.updated_tags,
|
|
&mut progress,
|
|
)
|
|
})
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|
|
|
|
fn new_note_log(dupe_resolution: DupeResolution, found_notes: u32) -> NoteLog {
|
|
NoteLog {
|
|
dupe_resolution: dupe_resolution as i32,
|
|
found_notes,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
struct Context<'a> {
|
|
col: &'a mut Collection,
|
|
/// Contains the optional default notetype with the default key.
|
|
notetypes: HashMap<NameOrId, Option<Arc<Notetype>>>,
|
|
deck_ids: DeckIdsByNameOrId,
|
|
usn: Usn,
|
|
normalize_notes: bool,
|
|
timing: SchedTimingToday,
|
|
dupe_resolution: DupeResolution,
|
|
card_gen_ctxs: HashMap<(NotetypeId, DeckId), CardGenContext<Arc<Notetype>>>,
|
|
existing_checksums: ExistingChecksums,
|
|
existing_guids: HashMap<String, NoteId>,
|
|
}
|
|
|
|
struct DeckIdsByNameOrId {
|
|
ids: HashSet<DeckId>,
|
|
names: HashMap<UniCase<String>, 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> {
|
|
note: ForeignNote,
|
|
dupes: Vec<Duplicate>,
|
|
notetype: Arc<Notetype>,
|
|
deck_id: DeckId,
|
|
global_tags: &'a [String],
|
|
updated_tags: &'a [String],
|
|
}
|
|
|
|
struct Duplicate {
|
|
note: Note,
|
|
identical: bool,
|
|
first_field_match: bool,
|
|
}
|
|
|
|
impl Duplicate {
|
|
fn new(dupe: Note, original: &ForeignNote, first_field_match: bool) -> Self {
|
|
let identical = original.equal_fields_and_tags(&dupe);
|
|
Self {
|
|
note: dupe,
|
|
identical,
|
|
first_field_match,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl DeckIdsByNameOrId {
|
|
fn new(col: &mut Collection, default: &NameOrId, usn: Usn) -> Result<Self> {
|
|
let names: HashMap<UniCase<String>, DeckId> = col
|
|
.get_all_normal_deck_names(false)?
|
|
.into_iter()
|
|
.map(|(id, name)| (UniCase::new(name), id))
|
|
.collect();
|
|
let ids = names.values().copied().collect();
|
|
let mut new = Self {
|
|
ids,
|
|
names,
|
|
default: None,
|
|
};
|
|
new.default = new.get(default);
|
|
if new.default.is_none() && *default != NameOrId::default() {
|
|
let mut deck = Deck::new_normal();
|
|
deck.name = NativeDeckName::from_human_name(default.to_string());
|
|
col.add_deck_inner(&mut deck, usn)?;
|
|
new.insert(deck.id, deck.human_name());
|
|
new.default = Some(deck.id);
|
|
}
|
|
|
|
Ok(new)
|
|
}
|
|
|
|
fn get(&self, name_or_id: &NameOrId) -> Option<DeckId> {
|
|
match name_or_id {
|
|
_ if *name_or_id == NameOrId::default() => self.default,
|
|
NameOrId::Id(id) => self
|
|
.ids
|
|
.get(&DeckId(*id))
|
|
// try treating it as a numeric deck name
|
|
.or_else(|| self.names.get(&UniCase::new(id.to_string())))
|
|
.copied(),
|
|
NameOrId::Name(name) => self.names.get(&UniCase::new(name.to_string())).copied(),
|
|
}
|
|
}
|
|
|
|
fn insert(&mut self, deck_id: DeckId, name: String) {
|
|
self.ids.insert(deck_id);
|
|
self.names.insert(UniCase::new(name), deck_id);
|
|
}
|
|
}
|
|
|
|
impl<'a> Context<'a> {
|
|
fn new(data: &ForeignData, col: &'a mut Collection) -> Result<Self> {
|
|
let usn = col.usn()?;
|
|
let normalize_notes = col.get_config_bool(BoolKey::NormalizeNoteText);
|
|
let timing = col.timing_today()?;
|
|
let mut notetypes = HashMap::new();
|
|
notetypes.insert(
|
|
NameOrId::default(),
|
|
col.notetype_by_name_or_id(&data.default_notetype)?,
|
|
);
|
|
let deck_ids = DeckIdsByNameOrId::new(col, &data.default_deck, usn)?;
|
|
let existing_checksums = ExistingChecksums::new(col, data.match_scope)?;
|
|
let existing_guids = col.storage.all_notes_by_guid()?;
|
|
|
|
Ok(Self {
|
|
col,
|
|
usn,
|
|
normalize_notes,
|
|
timing,
|
|
dupe_resolution: data.dupe_resolution,
|
|
notetypes,
|
|
deck_ids,
|
|
card_gen_ctxs: HashMap::new(),
|
|
existing_checksums,
|
|
existing_guids,
|
|
})
|
|
}
|
|
|
|
fn import_foreign_notetypes(&mut self, notetypes: Vec<ForeignNotetype>) -> Result<()> {
|
|
for foreign in notetypes {
|
|
let mut notetype = foreign.into_native();
|
|
notetype.usn = self.usn;
|
|
self.col
|
|
.add_notetype_inner(&mut notetype, self.usn, false)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn notetype_for_note(&mut self, note: &ForeignNote) -> Result<Option<Arc<Notetype>>> {
|
|
Ok(if let Some(nt) = self.notetypes.get(¬e.notetype) {
|
|
nt.clone()
|
|
} else {
|
|
let nt = self.col.notetype_by_name_or_id(¬e.notetype)?;
|
|
self.notetypes.insert(note.notetype.clone(), nt.clone());
|
|
nt
|
|
})
|
|
}
|
|
|
|
fn import_foreign_notes(
|
|
&mut self,
|
|
notes: Vec<ForeignNote>,
|
|
global_tags: &[String],
|
|
updated_tags: &[String],
|
|
progress: &mut ThrottlingProgressHandler<ImportProgress>,
|
|
) -> Result<NoteLog> {
|
|
let mut incrementor = progress.incrementor(ImportProgress::Notes);
|
|
let mut log = new_note_log(self.dupe_resolution, notes.len() as u32);
|
|
for foreign in notes {
|
|
incrementor.increment()?;
|
|
if foreign.first_field_is_the_empty_string() {
|
|
log.empty_first_field.push(foreign.into_log_note());
|
|
continue;
|
|
}
|
|
if let Some(notetype) = self.notetype_for_note(&foreign)? {
|
|
if let Some(deck_id) = self.get_or_create_deck_id(&foreign.deck)? {
|
|
let ctx = self.build_note_context(
|
|
foreign,
|
|
notetype,
|
|
deck_id,
|
|
global_tags,
|
|
updated_tags,
|
|
)?;
|
|
self.import_note(ctx, &mut log)?;
|
|
} else {
|
|
log.missing_deck.push(foreign.into_log_note());
|
|
}
|
|
} else {
|
|
log.missing_notetype.push(foreign.into_log_note());
|
|
}
|
|
}
|
|
Ok(log)
|
|
}
|
|
|
|
fn get_or_create_deck_id(&mut self, deck: &NameOrId) -> Result<Option<DeckId>> {
|
|
Ok(if let Some(did) = self.deck_ids.get(deck) {
|
|
Some(did)
|
|
} else if let NameOrId::Name(name) = deck {
|
|
let mut deck = Deck::new_normal();
|
|
deck.name = NativeDeckName::from_human_name(name);
|
|
self.col.add_deck_inner(&mut deck, self.usn)?;
|
|
self.deck_ids.insert(deck.id, deck.human_name());
|
|
if name.is_empty() {
|
|
self.deck_ids.default = Some(deck.id);
|
|
}
|
|
Some(deck.id)
|
|
} else {
|
|
None
|
|
})
|
|
}
|
|
|
|
fn build_note_context<'tags>(
|
|
&mut self,
|
|
mut note: ForeignNote,
|
|
notetype: Arc<Notetype>,
|
|
deck_id: DeckId,
|
|
global_tags: &'tags [String],
|
|
updated_tags: &'tags [String],
|
|
) -> Result<NoteContext<'tags>> {
|
|
self.prepare_foreign_note(&mut note)?;
|
|
let dupes = self.find_duplicates(¬etype, ¬e, deck_id)?;
|
|
Ok(NoteContext {
|
|
note,
|
|
dupes,
|
|
notetype,
|
|
deck_id,
|
|
global_tags,
|
|
updated_tags,
|
|
})
|
|
}
|
|
|
|
fn prepare_foreign_note(&mut self, note: &mut ForeignNote) -> Result<()> {
|
|
note.normalize_fields(self.normalize_notes);
|
|
self.col.canonify_foreign_tags(note, self.usn)
|
|
}
|
|
|
|
fn find_duplicates(
|
|
&self,
|
|
notetype: &Notetype,
|
|
note: &ForeignNote,
|
|
deck_id: DeckId,
|
|
) -> Result<Vec<Duplicate>> {
|
|
if note.guid.is_empty() {
|
|
if let Some(nids) = note
|
|
.checksum()
|
|
.and_then(|csum| self.existing_checksums.get(notetype.id, csum, deck_id))
|
|
{
|
|
return self.get_first_field_dupes(note, nids);
|
|
}
|
|
} else if let Some(nid) = self.existing_guids.get(¬e.guid) {
|
|
return self.get_guid_dupe(*nid, note).map(|dupe| vec![dupe]);
|
|
}
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
fn get_guid_dupe(&self, nid: NoteId, original: &ForeignNote) -> Result<Duplicate> {
|
|
self.col
|
|
.storage
|
|
.get_note(nid)?
|
|
.or_not_found(nid)
|
|
.map(|dupe| Duplicate::new(dupe, original, false))
|
|
}
|
|
|
|
fn get_first_field_dupes(&self, note: &ForeignNote, nids: &[NoteId]) -> Result<Vec<Duplicate>> {
|
|
Ok(self
|
|
.col
|
|
.get_full_duplicates(note, nids)?
|
|
.into_iter()
|
|
.map(|dupe| Duplicate::new(dupe, note, true))
|
|
.collect())
|
|
}
|
|
|
|
fn import_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
|
match self.dupe_resolution {
|
|
_ if ctx.dupes.is_empty() => self.add_note(ctx, log)?,
|
|
DupeResolution::Duplicate if ctx.is_guid_dupe() => log
|
|
.duplicate
|
|
.push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()),
|
|
DupeResolution::Duplicate if !ctx.has_first_field() => {
|
|
log.empty_first_field.push(ctx.note.into_log_note())
|
|
}
|
|
DupeResolution::Duplicate => self.add_note(ctx, log)?,
|
|
DupeResolution::Update => self.update_with_note(ctx, log)?,
|
|
DupeResolution::Preserve => log
|
|
.first_field_match
|
|
.push(ctx.dupes.into_iter().next().unwrap().note.into_log_note()),
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn add_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
|
let mut note = Note::new(&ctx.notetype);
|
|
let mut cards = ctx
|
|
.note
|
|
.into_native(&mut note, ctx.deck_id, &self.timing, ctx.global_tags);
|
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
|
self.col.add_note_only_undoable(&mut note)?;
|
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype)?;
|
|
|
|
if ctx.dupes.is_empty() {
|
|
log.new.push(note.into_log_note());
|
|
} else {
|
|
log.first_field_match.push(note.into_log_note());
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn add_cards(
|
|
&mut self,
|
|
cards: &mut [Card],
|
|
note: &Note,
|
|
deck_id: DeckId,
|
|
notetype: Arc<Notetype>,
|
|
) -> Result<()> {
|
|
self.import_cards(cards, note.id)?;
|
|
self.generate_missing_cards(notetype, deck_id, note)
|
|
}
|
|
|
|
fn update_with_note(&mut self, ctx: NoteContext, log: &mut NoteLog) -> Result<()> {
|
|
let mut update_result = DuplicateUpdateResult::None;
|
|
for dupe in ctx.dupes {
|
|
if dupe.note.notetype_id != ctx.notetype.id {
|
|
update_result.update(DuplicateUpdateResult::Conflicting(dupe));
|
|
continue;
|
|
}
|
|
|
|
let mut note = dupe.note.clone();
|
|
let mut cards = ctx.note.clone().into_native(
|
|
&mut note,
|
|
ctx.deck_id,
|
|
&self.timing,
|
|
ctx.global_tags.iter().chain(ctx.updated_tags.iter()),
|
|
);
|
|
|
|
if dupe.identical {
|
|
update_result.update(DuplicateUpdateResult::Identical(dupe));
|
|
} else {
|
|
self.prepare_note(&mut note, &ctx.notetype)?;
|
|
self.col.update_note_undoable(¬e, &dupe.note)?;
|
|
update_result.update(DuplicateUpdateResult::Update(dupe));
|
|
}
|
|
self.add_cards(&mut cards, ¬e, ctx.deck_id, ctx.notetype.clone())?;
|
|
}
|
|
update_result.log(log);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn prepare_note(&mut self, note: &mut Note, notetype: &Notetype) -> Result<()> {
|
|
note.prepare_for_update(notetype, self.normalize_notes)?;
|
|
self.col.canonify_note_tags(note, self.usn)?;
|
|
note.set_modified(self.usn);
|
|
Ok(())
|
|
}
|
|
|
|
fn import_cards(&mut self, cards: &mut [Card], note_id: NoteId) -> Result<()> {
|
|
for card in cards {
|
|
card.note_id = note_id;
|
|
self.col.add_card(card)?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn generate_missing_cards(
|
|
&mut self,
|
|
notetype: Arc<Notetype>,
|
|
deck_id: DeckId,
|
|
note: &Note,
|
|
) -> Result<()> {
|
|
let card_gen_context = self
|
|
.card_gen_ctxs
|
|
.entry((notetype.id, deck_id))
|
|
.or_insert_with(|| CardGenContext::new(notetype, Some(deck_id), self.usn));
|
|
self.col
|
|
.generate_cards_for_existing_note(card_gen_context, note)
|
|
}
|
|
}
|
|
|
|
/// Helper enum to decide which result to log if multiple duplicates were found
|
|
/// for a single incoming note.
|
|
enum DuplicateUpdateResult {
|
|
None,
|
|
Conflicting(Duplicate),
|
|
Identical(Duplicate),
|
|
Update(Duplicate),
|
|
}
|
|
|
|
impl DuplicateUpdateResult {
|
|
fn priority(&self) -> u8 {
|
|
match self {
|
|
DuplicateUpdateResult::None => 0,
|
|
DuplicateUpdateResult::Conflicting(_) => 1,
|
|
DuplicateUpdateResult::Identical(_) => 2,
|
|
DuplicateUpdateResult::Update(_) => 3,
|
|
}
|
|
}
|
|
|
|
fn update(&mut self, new: Self) {
|
|
if self.priority() < new.priority() {
|
|
*self = new;
|
|
}
|
|
}
|
|
|
|
fn log(self, log: &mut NoteLog) {
|
|
match self {
|
|
DuplicateUpdateResult::None => (),
|
|
DuplicateUpdateResult::Conflicting(dupe) => {
|
|
log.conflicting.push(dupe.note.into_log_note())
|
|
}
|
|
DuplicateUpdateResult::Identical(dupe) => log.duplicate.push(dupe.note.into_log_note()),
|
|
DuplicateUpdateResult::Update(dupe) if dupe.first_field_match => {
|
|
log.first_field_match.push(dupe.note.into_log_note())
|
|
}
|
|
DuplicateUpdateResult::Update(dupe) => log.updated.push(dupe.note.into_log_note()),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl NoteContext<'_> {
|
|
fn is_guid_dupe(&self) -> bool {
|
|
self.dupes
|
|
.first()
|
|
.is_some_and(|d| d.note.guid == self.note.guid)
|
|
}
|
|
|
|
fn has_first_field(&self) -> bool {
|
|
self.note.first_field_is_unempty()
|
|
}
|
|
}
|
|
|
|
impl Note {
|
|
fn first_field_stripped(&self) -> Cow<'_, str> {
|
|
strip_html_preserving_media_filenames(&self.fields()[0])
|
|
}
|
|
}
|
|
|
|
impl Collection {
|
|
pub(super) fn deck_id_by_name_or_id(&mut self, deck: &NameOrId) -> Result<Option<DeckId>> {
|
|
match deck {
|
|
NameOrId::Id(id) => Ok({
|
|
match self.get_deck(DeckId(*id))?.map(|d| d.id) {
|
|
did @ Some(_) => did,
|
|
// try treating it as a numeric deck name
|
|
_ => self.get_deck_id(&id.to_string())?,
|
|
}
|
|
}),
|
|
NameOrId::Name(name) => self.get_deck_id(name),
|
|
}
|
|
}
|
|
|
|
pub(super) fn notetype_by_name_or_id(
|
|
&mut self,
|
|
notetype: &NameOrId,
|
|
) -> Result<Option<Arc<Notetype>>> {
|
|
match notetype {
|
|
NameOrId::Id(id) => Ok({
|
|
match self.get_notetype(NotetypeId(*id))? {
|
|
nt @ Some(_) => nt,
|
|
// try treating it as a numeric notetype name
|
|
_ => self.get_notetype_by_name(&id.to_string())?,
|
|
}
|
|
}),
|
|
NameOrId::Name(name) => self.get_notetype_by_name(name),
|
|
}
|
|
}
|
|
|
|
fn canonify_foreign_tags(&mut self, note: &mut ForeignNote, usn: Usn) -> Result<()> {
|
|
if let Some(tags) = note.tags.take() {
|
|
note.tags
|
|
.replace(self.canonify_tags_without_registering(tags, usn)?);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn get_full_duplicates(&self, note: &ForeignNote, dupe_ids: &[NoteId]) -> Result<Vec<Note>> {
|
|
let first_field = note.first_field_stripped().or_invalid("no first field")?;
|
|
dupe_ids
|
|
.iter()
|
|
.filter_map(|&dupe_id| self.storage.get_note(dupe_id).transpose())
|
|
.filter(|res| match res {
|
|
Ok(dupe) => dupe.first_field_stripped() == first_field,
|
|
Err(_) => true,
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
impl ForeignNote {
|
|
/// Updates a native note with the foreign data and returns its new cards.
|
|
fn into_native<'tags>(
|
|
self,
|
|
note: &mut Note,
|
|
deck_id: DeckId,
|
|
timing: &SchedTimingToday,
|
|
extra_tags: impl IntoIterator<Item = &'tags String>,
|
|
) -> Vec<Card> {
|
|
// TODO: Handle new and learning cards
|
|
if !self.guid.is_empty() {
|
|
note.guid = self.guid;
|
|
}
|
|
if let Some(tags) = self.tags {
|
|
note.tags = tags;
|
|
}
|
|
note.tags.extend(extra_tags.into_iter().cloned());
|
|
note.fields_mut()
|
|
.iter_mut()
|
|
.zip(self.fields)
|
|
.for_each(|(field, new)| {
|
|
if let Some(s) = new {
|
|
*field = s;
|
|
}
|
|
});
|
|
self.cards
|
|
.into_iter()
|
|
.enumerate()
|
|
.map(|(idx, c)| c.into_native(NoteId(0), idx as u16, deck_id, timing))
|
|
.collect()
|
|
}
|
|
|
|
fn first_field_is_the_empty_string(&self) -> bool {
|
|
matches!(self.fields.first(), Some(Some(s)) if s.is_empty())
|
|
}
|
|
|
|
fn first_field_is_unempty(&self) -> bool {
|
|
matches!(self.fields.first(), Some(Some(s)) if !s.is_empty())
|
|
}
|
|
|
|
fn normalize_fields(&mut self, normalize_text: bool) {
|
|
for field in self.fields.iter_mut().flatten() {
|
|
normalize_field(field, normalize_text);
|
|
}
|
|
}
|
|
|
|
/// Expects normalized form.
|
|
fn equal_fields_and_tags(&self, other: &Note) -> bool {
|
|
self.tags.as_ref().map_or(true, |tags| *tags == other.tags)
|
|
&& self
|
|
.fields
|
|
.iter()
|
|
.zip(other.fields())
|
|
.all(|(opt, field)| opt.as_ref().map(|s| s == field).unwrap_or(true))
|
|
}
|
|
|
|
fn first_field_stripped(&self) -> Option<Cow<'_, str>> {
|
|
self.fields
|
|
.first()
|
|
.and_then(|s| s.as_ref())
|
|
.map(|field| strip_html_preserving_media_filenames(field.as_str()))
|
|
}
|
|
|
|
/// If the first field is set, returns its checksum. Field is expected to be
|
|
/// normalized.
|
|
fn checksum(&self) -> Option<u32> {
|
|
self.first_field_stripped()
|
|
.map(|field| field_checksum(&field))
|
|
}
|
|
}
|
|
|
|
impl ForeignCard {
|
|
fn into_native(
|
|
self,
|
|
note_id: NoteId,
|
|
template_idx: u16,
|
|
deck_id: DeckId,
|
|
timing: &SchedTimingToday,
|
|
) -> Card {
|
|
Card {
|
|
note_id,
|
|
template_idx,
|
|
deck_id,
|
|
due: self.native_due(timing),
|
|
interval: self.interval,
|
|
ease_factor: (self.ease_factor * 1000.).round() as u16,
|
|
reps: self.reps,
|
|
lapses: self.lapses,
|
|
ctype: CardType::Review,
|
|
queue: CardQueue::Review,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn native_due(self, timing: &SchedTimingToday) -> i32 {
|
|
let day_start = timing.next_day_at.0 - 86_400;
|
|
let due_delta = (self.due - day_start) / 86_400;
|
|
due_delta as i32 + timing.days_elapsed as i32
|
|
}
|
|
}
|
|
|
|
impl ForeignNotetype {
|
|
fn into_native(self) -> Notetype {
|
|
Notetype {
|
|
name: self.name,
|
|
fields: self.fields.into_iter().map(NoteField::new).collect(),
|
|
templates: self
|
|
.templates
|
|
.into_iter()
|
|
.map(ForeignTemplate::into_native)
|
|
.collect(),
|
|
config: if self.is_cloze {
|
|
Notetype::new_cloze_config()
|
|
} else {
|
|
Notetype::new_config()
|
|
},
|
|
..Notetype::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ForeignTemplate {
|
|
fn into_native(self) -> CardTemplate {
|
|
CardTemplate::new(self.name, self.qfmt, self.afmt)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod test {
|
|
use super::*;
|
|
use crate::tests::DeckAdder;
|
|
use crate::tests::NoteAdder;
|
|
|
|
impl ForeignData {
|
|
fn with_defaults() -> Self {
|
|
Self {
|
|
default_notetype: NameOrId::Name("Basic".to_string()),
|
|
default_deck: NameOrId::Id(1),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn add_note(&mut self, fields: &[&str]) {
|
|
self.notes.push(ForeignNote {
|
|
fields: fields.iter().map(ToString::to_string).map(Some).collect(),
|
|
..Default::default()
|
|
});
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn should_always_add_note_if_dupe_mode_is_add() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["same", "old"]);
|
|
data.dupe_resolution = DupeResolution::Duplicate;
|
|
|
|
let progress = col.new_progress_handler();
|
|
data.clone().import(&mut col, progress).unwrap();
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.notes_table_len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn should_add_or_ignore_note_if_dupe_mode_is_ignore() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["same", "old"]);
|
|
data.dupe_resolution = DupeResolution::Preserve;
|
|
let progress = col.new_progress_handler();
|
|
data.clone().import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.notes_table_len(), 1);
|
|
|
|
data.notes[0].fields[1].replace("new".to_string());
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
let notes = col.storage.get_all_notes();
|
|
assert_eq!(notes.len(), 1);
|
|
assert_eq!(notes[0].fields()[1], "old");
|
|
}
|
|
|
|
#[test]
|
|
fn should_update_or_add_note_if_dupe_mode_is_update() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["same", "old"]);
|
|
data.dupe_resolution = DupeResolution::Update;
|
|
let progress = col.new_progress_handler();
|
|
data.clone().import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.notes_table_len(), 1);
|
|
|
|
data.notes[0].fields[1].replace("new".to_string());
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.get_all_notes()[0].fields()[1], "new");
|
|
}
|
|
|
|
#[test]
|
|
fn should_keep_old_field_content_if_no_new_one_is_supplied() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["same", "unchanged"]);
|
|
data.add_note(&["same", "unchanged"]);
|
|
data.dupe_resolution = DupeResolution::Update;
|
|
let progress = col.new_progress_handler();
|
|
data.clone().import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.notes_table_len(), 2);
|
|
|
|
data.notes[0].fields[1] = None;
|
|
data.notes[1].fields.pop();
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
let notes = col.storage.get_all_notes();
|
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
|
assert_eq!(notes[0].fields(), &["same", "unchanged"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_recognize_normalized_duplicate_only_if_normalization_is_enabled() {
|
|
let mut col = Collection::new();
|
|
NoteAdder::basic(&mut col)
|
|
.fields(&["神", "old"])
|
|
.add(&mut col);
|
|
let mut data = ForeignData::with_defaults();
|
|
data.dupe_resolution = DupeResolution::Update;
|
|
data.add_note(&["神", "new"]);
|
|
let progress = col.new_progress_handler();
|
|
|
|
data.clone().import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.get_all_notes()[0].fields(), &["神", "new"]);
|
|
|
|
col.set_config_bool(BoolKey::NormalizeNoteText, false, false)
|
|
.unwrap();
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
let notes = col.storage.get_all_notes();
|
|
assert_eq!(notes[0].fields(), &["神", "new"]);
|
|
assert_eq!(notes[1].fields(), &["神", "new"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_add_global_tags() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["foo"]);
|
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
|
data.global_tags = vec![String::from("baz")];
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
assert_eq!(col.storage.get_all_notes()[0].tags, ["bar", "baz"]);
|
|
}
|
|
|
|
#[test]
|
|
fn should_match_note_with_same_guid() {
|
|
let mut col = Collection::new();
|
|
let mut data = ForeignData::with_defaults();
|
|
data.add_note(&["foo"]);
|
|
data.notes[0].tags.replace(vec![String::from("bar")]);
|
|
data.global_tags = vec![String::from("baz")];
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).unwrap();
|
|
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 = Collection::new();
|
|
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"]);
|
|
let progress = col.new_progress_handler();
|
|
data.import(&mut col, progress).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");
|
|
}
|
|
}
|