anki/rslib/src/findreplace.rs

165 lines
5.0 KiB
Rust
Raw Normal View History

2020-05-05 12:50:17 +02:00
// 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 regex::Regex;
use crate::collection::Collection;
use crate::error::Result;
use crate::notes::NoteId;
use crate::notes::TransformNoteOutput;
use crate::prelude::*;
use crate::text::normalize_to_nfc;
2020-05-05 12:50:17 +02:00
pub struct FindReplaceContext {
nids: Vec<NoteId>,
2020-05-05 12:50:17 +02:00
search: Regex,
replacement: String,
field_name: Option<String>,
}
enum FieldForNotetype {
Any,
Index(usize),
None,
}
2020-05-05 12:50:17 +02:00
impl FindReplaceContext {
pub fn new(
nids: Vec<NoteId>,
2020-05-05 12:50:17 +02:00
search_re: &str,
repl: impl Into<String>,
field_name: Option<String>,
) -> Result<Self> {
Ok(FindReplaceContext {
nids,
search: Regex::new(search_re)?,
2020-05-05 12:50:17 +02:00
replacement: repl.into(),
field_name,
})
}
fn replace_text<'a>(&self, text: &'a str) -> Cow<'a, str> {
self.search.replace_all(text, self.replacement.as_str())
}
}
impl Collection {
pub fn find_and_replace(
&mut self,
nids: Vec<NoteId>,
search_re: &str,
repl: &str,
field_name: Option<String>,
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
) -> Result<OpOutput<usize>> {
self.transact(Op::FindAndReplace, |col| {
let norm = col.get_config_bool(BoolKey::NormalizeNoteText);
let search = if norm {
normalize_to_nfc(search_re)
} else {
search_re.into()
};
let ctx = FindReplaceContext::new(nids, &search, repl, field_name)?;
col.find_and_replace_inner(ctx)
})
2020-05-05 12:50:17 +02:00
}
fn find_and_replace_inner(&mut self, ctx: FindReplaceContext) -> Result<usize> {
let mut last_ntid = None;
let mut field_for_notetype = FieldForNotetype::None;
self.transform_notes(&ctx.nids, |note, nt| {
if last_ntid != Some(nt.id) {
field_for_notetype = match ctx.field_name.as_ref() {
None => FieldForNotetype::Any,
Some(name) => match nt.get_field_ord(name) {
None => FieldForNotetype::None,
Some(ord) => FieldForNotetype::Index(ord),
},
};
last_ntid = Some(nt.id);
}
let mut changed = false;
match field_for_notetype {
FieldForNotetype::Any => {
for txt in note.fields_mut() {
if let Cow::Owned(otxt) = ctx.replace_text(txt) {
changed = true;
*txt = otxt;
2020-05-05 12:50:17 +02:00
}
}
}
FieldForNotetype::Index(ord) => {
if let Some(txt) = note.fields_mut().get_mut(ord) {
if let Cow::Owned(otxt) = ctx.replace_text(txt) {
changed = true;
*txt = otxt;
2020-05-05 12:50:17 +02:00
}
}
}
FieldForNotetype::None => (),
2020-05-05 12:50:17 +02:00
}
Ok(TransformNoteOutput {
changed,
generate_cards: true,
mark_modified: true,
update_tags: false,
})
})
2020-05-05 12:50:17 +02:00
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::decks::DeckId;
2020-05-05 12:50:17 +02:00
#[test]
fn findreplace() -> Result<()> {
let mut col = Collection::new();
2020-05-05 12:50:17 +02:00
let nt = col.get_notetype_by_name("Basic")?.unwrap();
let mut note = nt.new_note();
note.set_field(0, "one aaa")?;
note.set_field(1, "two aaa")?;
col.add_note(&mut note, DeckId(1))?;
2020-05-05 12:50:17 +02:00
let nt = col.get_notetype_by_name("Cloze")?.unwrap();
let mut note2 = nt.new_note();
note2.set_field(0, "three aaa")?;
col.add_note(&mut note2, DeckId(1))?;
2020-05-05 12:50:17 +02:00
let nids = col.search_notes_unordered("")?;
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
let out = col.find_and_replace(nids.clone(), "(?i)AAA", "BBB", None)?;
assert_eq!(out.output, 2);
2020-05-05 12:50:17 +02:00
let note = col.storage.get_note(note.id)?.unwrap();
// but the update should be limited to the specified field when it was available
assert_eq!(&note.fields()[..], &["one BBB", "two BBB"]);
2020-05-05 12:50:17 +02:00
let note2 = col.storage.get_note(note2.id)?.unwrap();
assert_eq!(&note2.fields()[..], &["three BBB", ""]);
2020-05-05 12:50:17 +02:00
assert_eq!(
col.storage.field_names_for_notes(&nids)?,
vec![
"Back".to_string(),
"Back Extra".into(),
"Front".into(),
"Text".into()
]
2020-05-05 12:50:17 +02:00
);
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
let out = col.find_and_replace(nids, "BBB", "ccc", Some("Front".into()))?;
// 1, because notes without the specified field should be skipped
assert_eq!(out.output, 1);
2020-05-05 12:50:17 +02:00
let note = col.storage.get_note(note.id)?.unwrap();
// the update should be limited to the specified field when it was available
assert_eq!(&note.fields()[..], &["one ccc", "two BBB"]);
2020-05-05 12:50:17 +02:00
Ok(())
}
}