mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
move find&replace to backend
This commit is contained in:
parent
c51ca666c3
commit
8b557ec382
9 changed files with 293 additions and 63 deletions
|
@ -90,6 +90,8 @@ message BackendInput {
|
||||||
bool new_deck_legacy = 75;
|
bool new_deck_legacy = 75;
|
||||||
int64 remove_deck = 76;
|
int64 remove_deck = 76;
|
||||||
Empty deck_tree_legacy = 77;
|
Empty deck_tree_legacy = 77;
|
||||||
|
FieldNamesForNotesIn field_names_for_notes = 78;
|
||||||
|
FindAndReplaceIn find_and_replace = 79;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -159,6 +161,8 @@ message BackendOutput {
|
||||||
bytes new_deck_legacy = 75;
|
bytes new_deck_legacy = 75;
|
||||||
Empty remove_deck = 76;
|
Empty remove_deck = 76;
|
||||||
bytes deck_tree_legacy = 77;
|
bytes deck_tree_legacy = 77;
|
||||||
|
FieldNamesForNotesOut field_names_for_notes = 78;
|
||||||
|
uint32 find_and_replace = 79;
|
||||||
|
|
||||||
BackendError error = 2047;
|
BackendError error = 2047;
|
||||||
}
|
}
|
||||||
|
@ -712,3 +716,19 @@ message AddOrUpdateDeckLegacyIn {
|
||||||
bool preserve_usn_and_mtime = 2;
|
bool preserve_usn_and_mtime = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message FieldNamesForNotesIn {
|
||||||
|
repeated int64 nids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FieldNamesForNotesOut {
|
||||||
|
repeated string fields = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message FindAndReplaceIn {
|
||||||
|
repeated int64 nids = 1;
|
||||||
|
string search = 2;
|
||||||
|
string replacement = 3;
|
||||||
|
bool regex = 4;
|
||||||
|
bool match_case = 5;
|
||||||
|
string field_name = 6;
|
||||||
|
}
|
||||||
|
|
|
@ -3,11 +3,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
|
||||||
from typing import TYPE_CHECKING, Optional, Set
|
from typing import TYPE_CHECKING, Optional, Set
|
||||||
|
|
||||||
from anki.hooks import *
|
from anki.hooks import *
|
||||||
from anki.utils import ids2str, intTime, joinFields, splitFields, stripHTMLMedia
|
from anki.utils import ids2str, splitFields, stripHTMLMedia
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from anki.collection import _Collection
|
from anki.collection import _Collection
|
||||||
|
@ -38,56 +37,16 @@ def findReplace(
|
||||||
field: Optional[str] = None,
|
field: Optional[str] = None,
|
||||||
fold: bool = True,
|
fold: bool = True,
|
||||||
) -> int:
|
) -> int:
|
||||||
"Find and replace fields in a note."
|
"Find and replace fields in a note. Returns changed note count."
|
||||||
mmap: Dict[str, Any] = {}
|
return col.backend.find_and_replace(nids, src, dst, regex, fold, field)
|
||||||
if field:
|
|
||||||
for m in col.models.all():
|
|
||||||
for f in m["flds"]:
|
|
||||||
if f["name"].lower() == field.lower():
|
|
||||||
mmap[str(m["id"])] = f["ord"]
|
|
||||||
if not mmap:
|
|
||||||
return 0
|
|
||||||
# find and gather replacements
|
|
||||||
if not regex:
|
|
||||||
src = re.escape(src)
|
|
||||||
dst = dst.replace("\\", "\\\\")
|
|
||||||
if fold:
|
|
||||||
src = "(?i)" + src
|
|
||||||
compiled_re = re.compile(src)
|
|
||||||
|
|
||||||
def repl(s: str):
|
|
||||||
return compiled_re.sub(dst, s)
|
|
||||||
|
|
||||||
d = []
|
def fieldNamesForNotes(col: _Collection, nids: List[int]) -> List[str]:
|
||||||
snids = ids2str(nids)
|
return list(col.backend.field_names_for_note_ids(nids))
|
||||||
nids = []
|
|
||||||
for nid, mid, flds in col.db.execute(
|
|
||||||
"select id, mid, flds from notes where id in " + snids
|
# Find duplicates
|
||||||
):
|
##########################################################################
|
||||||
origFlds = flds
|
|
||||||
# does it match?
|
|
||||||
sflds = splitFields(flds)
|
|
||||||
if field:
|
|
||||||
try:
|
|
||||||
ord = mmap[str(mid)]
|
|
||||||
sflds[ord] = repl(sflds[ord])
|
|
||||||
except KeyError:
|
|
||||||
# note doesn't have that field
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
for c in range(len(sflds)):
|
|
||||||
sflds[c] = repl(sflds[c])
|
|
||||||
flds = joinFields(sflds)
|
|
||||||
if flds != origFlds:
|
|
||||||
nids.append(nid)
|
|
||||||
d.append((flds, intTime(), col.usn(), nid))
|
|
||||||
if not d:
|
|
||||||
return 0
|
|
||||||
# replace
|
|
||||||
col.db.executemany("update notes set flds=?,mod=?,usn=? where id=?", d)
|
|
||||||
col.updateFieldCache(nids)
|
|
||||||
col.genCards(nids)
|
|
||||||
return len(d)
|
|
||||||
|
|
||||||
|
|
||||||
def fieldNames(col, downcase=True) -> List:
|
def fieldNames(col, downcase=True) -> List:
|
||||||
|
@ -100,19 +59,6 @@ def fieldNames(col, downcase=True) -> List:
|
||||||
return list(fields)
|
return list(fields)
|
||||||
|
|
||||||
|
|
||||||
def fieldNamesForNotes(col, nids) -> List:
|
|
||||||
fields: Set[str] = set()
|
|
||||||
mids = col.db.list("select distinct mid from notes where id in %s" % ids2str(nids))
|
|
||||||
for mid in mids:
|
|
||||||
model = col.models.get(mid)
|
|
||||||
for name in col.models.fieldNames(model):
|
|
||||||
if name not in fields: # slower w/o
|
|
||||||
fields.add(name)
|
|
||||||
return sorted(fields, key=lambda x: x.lower())
|
|
||||||
|
|
||||||
|
|
||||||
# Find duplicates
|
|
||||||
##########################################################################
|
|
||||||
# returns array of ("dupestr", [nids])
|
# returns array of ("dupestr", [nids])
|
||||||
def findDupes(
|
def findDupes(
|
||||||
col: _Collection, fieldName: str, search: str = ""
|
col: _Collection, fieldName: str, search: str = ""
|
||||||
|
|
|
@ -745,6 +745,33 @@ class RustBackend:
|
||||||
).deck_tree_legacy
|
).deck_tree_legacy
|
||||||
return orjson.loads(bytes)[5]
|
return orjson.loads(bytes)[5]
|
||||||
|
|
||||||
|
def field_names_for_note_ids(self, nids: List[int]) -> Sequence[str]:
|
||||||
|
return self._run_command(
|
||||||
|
pb.BackendInput(field_names_for_notes=pb.FieldNamesForNotesIn(nids=nids))
|
||||||
|
).field_names_for_notes.fields
|
||||||
|
|
||||||
|
def find_and_replace(
|
||||||
|
self,
|
||||||
|
nids: List[int],
|
||||||
|
search: str,
|
||||||
|
repl: str,
|
||||||
|
re: bool,
|
||||||
|
nocase: bool,
|
||||||
|
field_name: Optional[str],
|
||||||
|
) -> int:
|
||||||
|
return self._run_command(
|
||||||
|
pb.BackendInput(
|
||||||
|
find_and_replace=pb.FindAndReplaceIn(
|
||||||
|
nids=nids,
|
||||||
|
search=search,
|
||||||
|
replacement=repl,
|
||||||
|
regex=re,
|
||||||
|
match_case=not nocase,
|
||||||
|
field_name=field_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).find_and_replace
|
||||||
|
|
||||||
|
|
||||||
def translate_string_in(
|
def translate_string_in(
|
||||||
key: TR, **kwargs: Union[str, int, float]
|
key: TR, **kwargs: Union[str, int, float]
|
||||||
|
|
|
@ -13,6 +13,7 @@ use crate::{
|
||||||
deckconf::{DeckConf, DeckConfID},
|
deckconf::{DeckConf, DeckConfID},
|
||||||
decks::{Deck, DeckID, DeckSchema11},
|
decks::{Deck, DeckID, DeckSchema11},
|
||||||
err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind},
|
err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind},
|
||||||
|
findreplace::FindReplaceContext,
|
||||||
i18n::{tr_args, I18n, TR},
|
i18n::{tr_args, I18n, TR},
|
||||||
latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex},
|
latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex},
|
||||||
log,
|
log,
|
||||||
|
@ -361,6 +362,10 @@ impl Backend {
|
||||||
OValue::CheckDatabase(pb::Empty {})
|
OValue::CheckDatabase(pb::Empty {})
|
||||||
}
|
}
|
||||||
Value::DeckTreeLegacy(_) => OValue::DeckTreeLegacy(self.deck_tree_legacy()?),
|
Value::DeckTreeLegacy(_) => OValue::DeckTreeLegacy(self.deck_tree_legacy()?),
|
||||||
|
Value::FieldNamesForNotes(input) => {
|
||||||
|
OValue::FieldNamesForNotes(self.field_names_for_notes(input)?)
|
||||||
|
}
|
||||||
|
Value::FindAndReplace(input) => OValue::FindAndReplace(self.find_and_replace(input)?),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1056,6 +1061,39 @@ impl Backend {
|
||||||
serde_json::to_vec(&tree).map_err(Into::into)
|
serde_json::to_vec(&tree).map_err(Into::into)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn field_names_for_notes(
|
||||||
|
&self,
|
||||||
|
input: pb::FieldNamesForNotesIn,
|
||||||
|
) -> Result<pb::FieldNamesForNotesOut> {
|
||||||
|
self.with_col(|col| {
|
||||||
|
let nids: Vec<_> = input.nids.into_iter().map(NoteID).collect();
|
||||||
|
col.storage
|
||||||
|
.field_names_for_notes(&nids)
|
||||||
|
.map(|fields| pb::FieldNamesForNotesOut { fields })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_and_replace(&self, input: pb::FindAndReplaceIn) -> Result<u32> {
|
||||||
|
let mut search = if input.regex {
|
||||||
|
input.search
|
||||||
|
} else {
|
||||||
|
regex::escape(&input.search)
|
||||||
|
};
|
||||||
|
if !input.match_case {
|
||||||
|
search = format!("(?i){}", search);
|
||||||
|
}
|
||||||
|
let nids = input.nids.into_iter().map(NoteID).collect();
|
||||||
|
let field_name = if input.field_name.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(input.field_name)
|
||||||
|
};
|
||||||
|
let repl = input.replacement;
|
||||||
|
self.with_col(|col| {
|
||||||
|
col.find_and_replace(FindReplaceContext::new(nids, &search, &repl, field_name)?)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue {
|
fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue {
|
||||||
|
|
146
rslib/src/findreplace.rs
Normal file
146
rslib/src/findreplace.rs
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
collection::Collection,
|
||||||
|
err::{AnkiError, Result},
|
||||||
|
notes::NoteID,
|
||||||
|
notetype::CardGenContext,
|
||||||
|
types::Usn,
|
||||||
|
};
|
||||||
|
use itertools::Itertools;
|
||||||
|
use regex::Regex;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
pub struct FindReplaceContext {
|
||||||
|
nids: Vec<NoteID>,
|
||||||
|
search: Regex,
|
||||||
|
replacement: String,
|
||||||
|
field_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FindReplaceContext {
|
||||||
|
pub fn new(
|
||||||
|
nids: Vec<NoteID>,
|
||||||
|
search_re: &str,
|
||||||
|
repl: impl Into<String>,
|
||||||
|
field_name: Option<String>,
|
||||||
|
) -> Result<Self> {
|
||||||
|
Ok(FindReplaceContext {
|
||||||
|
nids,
|
||||||
|
search: Regex::new(search_re).map_err(|_| AnkiError::invalid_input("invalid regex"))?,
|
||||||
|
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, ctx: FindReplaceContext) -> Result<u32> {
|
||||||
|
self.transact(None, |col| col.find_and_replace_inner(ctx, col.usn()?))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_and_replace_inner(&mut self, ctx: FindReplaceContext, usn: Usn) -> Result<u32> {
|
||||||
|
let mut total_changed = 0;
|
||||||
|
let nids_by_notetype = self.storage.note_ids_by_notetype(&ctx.nids)?;
|
||||||
|
for (ntid, group) in &nids_by_notetype.into_iter().group_by(|tup| tup.0) {
|
||||||
|
let nt = self
|
||||||
|
.get_notetype(ntid)?
|
||||||
|
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;
|
||||||
|
let genctx = CardGenContext::new(&nt, usn);
|
||||||
|
let field_ord = ctx.field_name.as_ref().and_then(|n| nt.get_field_ord(n));
|
||||||
|
for (_, nid) in group {
|
||||||
|
let mut note = self.storage.get_note(nid)?.unwrap();
|
||||||
|
let mut changed = false;
|
||||||
|
match field_ord {
|
||||||
|
None => {
|
||||||
|
// all fields
|
||||||
|
for txt in &mut note.fields {
|
||||||
|
if let Cow::Owned(otxt) = ctx.replace_text(txt) {
|
||||||
|
changed = true;
|
||||||
|
*txt = otxt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(ord) => {
|
||||||
|
// single field
|
||||||
|
if let Some(txt) = note.fields.get_mut(ord) {
|
||||||
|
if let Cow::Owned(otxt) = ctx.replace_text(txt) {
|
||||||
|
changed = true;
|
||||||
|
*txt = otxt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
self.update_note_inner(&genctx, &mut note)?;
|
||||||
|
total_changed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(total_changed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::*;
|
||||||
|
use crate::{collection::open_test_collection, decks::DeckID};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn findreplace() -> Result<()> {
|
||||||
|
let mut col = open_test_collection();
|
||||||
|
|
||||||
|
let nt = col.get_notetype_by_name("Basic")?.unwrap();
|
||||||
|
let mut note = nt.new_note();
|
||||||
|
note.fields[0] = "one aaa".into();
|
||||||
|
note.fields[1] = "two aaa".into();
|
||||||
|
col.add_note(&mut note, DeckID(1))?;
|
||||||
|
|
||||||
|
let nt = col.get_notetype_by_name("Cloze")?.unwrap();
|
||||||
|
let mut note2 = nt.new_note();
|
||||||
|
note2.fields[0] = "three aaa".into();
|
||||||
|
col.add_note(&mut note2, DeckID(1))?;
|
||||||
|
|
||||||
|
let nids = col.search_notes_only("")?;
|
||||||
|
let cnt = col.find_and_replace(FindReplaceContext::new(
|
||||||
|
nids.clone(),
|
||||||
|
"(?i)AAA",
|
||||||
|
"BBB",
|
||||||
|
None,
|
||||||
|
)?)?;
|
||||||
|
assert_eq!(cnt, 2);
|
||||||
|
|
||||||
|
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!(¬e.fields, &["one BBB", "two BBB"]);
|
||||||
|
|
||||||
|
let note2 = col.storage.get_note(note2.id)?.unwrap();
|
||||||
|
assert_eq!(¬e2.fields, &["three BBB"]);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
col.storage.field_names_for_notes(&nids)?,
|
||||||
|
vec!["Back".to_string(), "Front".into(), "Text".into()]
|
||||||
|
);
|
||||||
|
let cnt = col.find_and_replace(FindReplaceContext::new(
|
||||||
|
nids.clone(),
|
||||||
|
"BBB",
|
||||||
|
"ccc",
|
||||||
|
Some("Front".into()),
|
||||||
|
)?)?;
|
||||||
|
// still 2, as the caller is expected to provide only note ids that have
|
||||||
|
// that field, and if we can't find the field we fall back on all fields
|
||||||
|
assert_eq!(cnt, 2);
|
||||||
|
|
||||||
|
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!(¬e.fields, &["one ccc", "two BBB"]);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,6 +18,7 @@ pub mod dbcheck;
|
||||||
pub mod deckconf;
|
pub mod deckconf;
|
||||||
pub mod decks;
|
pub mod decks;
|
||||||
pub mod err;
|
pub mod err;
|
||||||
|
pub mod findreplace;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod latex;
|
pub mod latex;
|
||||||
pub mod log;
|
pub mod log;
|
||||||
|
|
|
@ -294,6 +294,22 @@ impl NoteType {
|
||||||
fn fix_field_names(&mut self) {
|
fn fix_field_names(&mut self) {
|
||||||
self.fields.iter_mut().for_each(NoteField::fix_name);
|
self.fields.iter_mut().for_each(NoteField::fix_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find the field index of the provided field name.
|
||||||
|
pub(crate) fn get_field_ord(&self, field_name: &str) -> Option<usize> {
|
||||||
|
let field_name = UniCase::new(field_name);
|
||||||
|
self.fields
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, f)| {
|
||||||
|
if UniCase::new(&f.name) == field_name {
|
||||||
|
Some(idx)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.next()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<NoteType> for NoteTypeProto {
|
impl From<NoteType> for NoteTypeProto {
|
||||||
|
|
10
rslib/src/storage/notetype/field_names_for_notes.sql
Normal file
10
rslib/src/storage/notetype/field_names_for_notes.sql
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
select
|
||||||
|
distinct name
|
||||||
|
from fields
|
||||||
|
where
|
||||||
|
ntid in (
|
||||||
|
select
|
||||||
|
mid
|
||||||
|
from notes
|
||||||
|
where
|
||||||
|
id in
|
|
@ -131,6 +131,32 @@ impl SqliteStorage {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sorted list of all field names used by provided notes, for use with
|
||||||
|
/// the find&replace feature.
|
||||||
|
pub(crate) fn field_names_for_notes(&self, nids: &[NoteID]) -> Result<Vec<String>> {
|
||||||
|
let mut sql = include_str!("field_names_for_notes.sql").to_string();
|
||||||
|
sql.push(' ');
|
||||||
|
ids_to_string(&mut sql, nids);
|
||||||
|
sql += ") order by name";
|
||||||
|
self.db
|
||||||
|
.prepare(&sql)?
|
||||||
|
.query_and_then(NO_PARAMS, |r| r.get(0).map_err(Into::into))?
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn note_ids_by_notetype(
|
||||||
|
&self,
|
||||||
|
nids: &[NoteID],
|
||||||
|
) -> Result<Vec<(NoteTypeID, NoteID)>> {
|
||||||
|
let mut sql = String::from("select mid, id from notes where id in ");
|
||||||
|
ids_to_string(&mut sql, nids);
|
||||||
|
sql += " order by mid";
|
||||||
|
self.db
|
||||||
|
.prepare(&sql)?
|
||||||
|
.query_and_then(NO_PARAMS, |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn update_notetype_templates(
|
pub(crate) fn update_notetype_templates(
|
||||||
&self,
|
&self,
|
||||||
ntid: NoteTypeID,
|
ntid: NoteTypeID,
|
||||||
|
|
Loading…
Reference in a new issue