make mark toggling undoable

- note.flush() behaves like before, as otherwise actions or add-ons
that perform bulk flushing would end up creating an undo entry for
each note
- added col.update_note() to opt in to the new behaviour
- tidy up the names of some related routines
This commit is contained in:
Damien Elmes 2021-03-05 22:45:55 +10:00
parent bec77fd420
commit f1a1b0891e
17 changed files with 112 additions and 68 deletions

View File

@ -13,3 +13,4 @@ undo-answer-card = Answer Card
undo-unbury-unsuspend = Unbury/Unsuspend
undo-add-note = Add Note
undo-update-tag = Update Tag
undo-update-note = Update Note

View File

@ -141,7 +141,7 @@ class Card:
def note(self, reload: bool = False) -> Note:
if not self._note or reload:
self._note = self.col.getNote(self.nid)
self._note = self.col.get_note(self.nid)
return self._note
def note_type(self) -> NoteType:

View File

@ -322,9 +322,16 @@ class Collection:
def getCard(self, id: int) -> Card:
return Card(self, id)
def getNote(self, id: int) -> Note:
def get_note(self, id: int) -> Note:
return Note(self, id=id)
def update_note(self, note: Note) -> None:
"""Save note changes to database, and add an undo entry.
Unlike note.flush(), this will invalidate any current checkpoint."""
self._backend.update_note(note=note.to_backend_note(), skip_undo_entry=False)
getNote = get_note
# Utils
##########################################################################
@ -789,7 +796,7 @@ table.review-log {{ {revlog_style} }}
if not isinstance(self._undo, _ReviewsUndo):
self._undo = _ReviewsUndo()
was_leech = card.note().hasTag("leech")
was_leech = card.note().has_tag("leech")
entry = ReviewUndo(card=copy.copy(card), was_leech=was_leech)
self._undo.entries.append(entry)
@ -824,8 +831,8 @@ table.review-log {{ {revlog_style} }}
card = entry.card
# remove leech tag if it didn't have it before
if not entry.was_leech and card.note().hasTag("leech"):
card.note().delTag("leech")
if not entry.was_leech and card.note().has_tag("leech"):
card.note().remove_tag("leech")
card.note().flush()
# write old data

View File

@ -66,8 +66,10 @@ class Note:
)
def flush(self) -> None:
"""This preserves any current checkpoint.
For an undo entry, use col.update_note() instead."""
assert self.id != 0
self.col._backend.update_note(self.to_backend_note())
self.col._backend.update_note(note=self.to_backend_note(), skip_undo_entry=True)
def __repr__(self) -> str:
d = dict(self.__dict__)
@ -154,16 +156,10 @@ class Note:
# Tags
##################################################
def hasTag(self, tag: str) -> bool:
def has_tag(self, tag: str) -> bool:
return self.col.tags.inList(tag, self.tags)
def stringTags(self) -> Any:
return self.col.tags.join(self.col.tags.canonify(self.tags))
def setTagsFromStr(self, tags: str) -> None:
self.tags = self.col.tags.split(tags)
def delTag(self, tag: str) -> None:
def remove_tag(self, tag: str) -> None:
rem = []
for t in self.tags:
if t.lower() == tag.lower():
@ -171,10 +167,20 @@ class Note:
for r in rem:
self.tags.remove(r)
def addTag(self, tag: str) -> None:
# duplicates will be stripped on save
def add_tag(self, tag: str) -> None:
"Add tag. Duplicates will be stripped on save."
self.tags.append(tag)
def stringTags(self) -> Any:
return self.col.tags.join(self.col.tags.canonify(self.tags))
def setTagsFromStr(self, tags: str) -> None:
self.tags = self.col.tags.split(tags)
hasTag = has_tag
addTag = add_tag
delTag = remove_tag
# Unique/duplicate check
##################################################

View File

@ -606,7 +606,7 @@ did = ? and queue = {QUEUE_TYPE_REV} and due <= ? limit ?""",
if card.lapses >= lf and (card.lapses - lf) % (max(lf // 2, 1)) == 0:
# add a leech tag
f = card.note()
f.addTag("leech")
f.add_tag("leech")
f.flush()
# handle
a = conf["leechAction"]

View File

@ -411,7 +411,7 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
self.set_due_date(card_ids, f"{min_interval}-{max_interval}!")
def buryNote(self, nid: int) -> None:
note = self.col.getNote(nid)
note = self.col.get_note(nid)
self.bury_cards(note.card_ids())
def unburyCards(self) -> None:

View File

@ -1078,7 +1078,7 @@ limit ?"""
if card.lapses >= lf and (card.lapses - lf) % (max(lf // 2, 1)) == 0:
# add a leech tag
f = card.note()
f.addTag("leech")
f.add_tag("leech")
f.flush()
# handle
a = conf["leechAction"]
@ -1323,7 +1323,7 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
self.set_due_date(card_ids, f"{min_interval}-{max_interval}!")
def buryNote(self, nid: int) -> None:
note = self.col.getNote(nid)
note = self.col.get_note(nid)
self.bury_cards(note.card_ids())
def unburyCards(self) -> None:

View File

@ -51,7 +51,7 @@ def test_anki2_mediadupes():
imp = Anki2Importer(empty, col.path)
imp.run()
assert os.listdir(empty.media.dir()) == ["foo.mp3"]
n = empty.getNote(empty.db.scalar("select id from notes"))
n = empty.get_note(empty.db.scalar("select id from notes"))
assert "foo.mp3" in n.fields[0]
# if the local file content is different, and import should trigger a
# rename
@ -61,7 +61,7 @@ def test_anki2_mediadupes():
imp = Anki2Importer(empty, col.path)
imp.run()
assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid]
n = empty.getNote(empty.db.scalar("select id from notes"))
n = empty.get_note(empty.db.scalar("select id from notes"))
assert "_" in n.fields[0]
# if the localized media file already exists, we rewrite the note and
# media
@ -72,7 +72,7 @@ def test_anki2_mediadupes():
imp.run()
assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid]
assert sorted(os.listdir(empty.media.dir())) == ["foo.mp3", "foo_%s.mp3" % mid]
n = empty.getNote(empty.db.scalar("select id from notes"))
n = empty.get_note(empty.db.scalar("select id from notes"))
assert "_" in n.fields[0]
@ -162,8 +162,8 @@ def test_csv():
assert len(i.log) == 10
assert i.total == 5
# but importing should not clobber tags if they're unmapped
n = col.getNote(col.db.scalar("select id from notes"))
n.addTag("test")
n = col.get_note(col.db.scalar("select id from notes"))
n.add_tag("test")
n.flush()
i.run()
n.load()
@ -217,7 +217,7 @@ def test_tsv_tag_modified():
n["Front"] = "1"
n["Back"] = "2"
n["Top"] = "3"
n.addTag("four")
n.add_tag("four")
col.addNote(n)
# https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file
@ -253,8 +253,8 @@ def test_tsv_tag_multiple_tags():
n["Front"] = "1"
n["Back"] = "2"
n["Top"] = "3"
n.addTag("four")
n.addTag("five")
n.add_tag("four")
n.add_tag("five")
col.addNote(n)
# https://stackoverflow.com/questions/23212435/permission-denied-to-write-to-my-temporary-file

View File

@ -46,37 +46,37 @@ def test_fields():
# add a field
field = col.models.newField("foo")
col.models.addField(m, field)
assert col.getNote(col.models.nids(m)[0]).fields == ["1", "2", ""]
assert col.get_note(col.models.nids(m)[0]).fields == ["1", "2", ""]
assert col.models.scmhash(m) != h
# rename it
field = m["flds"][2]
col.models.renameField(m, field, "bar")
assert col.getNote(col.models.nids(m)[0])["bar"] == ""
assert col.get_note(col.models.nids(m)[0])["bar"] == ""
# delete back
col.models.remField(m, m["flds"][1])
assert col.getNote(col.models.nids(m)[0]).fields == ["1", ""]
assert col.get_note(col.models.nids(m)[0]).fields == ["1", ""]
# move 0 -> 1
col.models.moveField(m, m["flds"][0], 1)
assert col.getNote(col.models.nids(m)[0]).fields == ["", "1"]
assert col.get_note(col.models.nids(m)[0]).fields == ["", "1"]
# move 1 -> 0
col.models.moveField(m, m["flds"][1], 0)
assert col.getNote(col.models.nids(m)[0]).fields == ["1", ""]
assert col.get_note(col.models.nids(m)[0]).fields == ["1", ""]
# add another and put in middle
field = col.models.newField("baz")
col.models.addField(m, field)
note = col.getNote(col.models.nids(m)[0])
note = col.get_note(col.models.nids(m)[0])
note["baz"] = "2"
note.flush()
assert col.getNote(col.models.nids(m)[0]).fields == ["1", "", "2"]
assert col.get_note(col.models.nids(m)[0]).fields == ["1", "", "2"]
# move 2 -> 1
col.models.moveField(m, m["flds"][2], 1)
assert col.getNote(col.models.nids(m)[0]).fields == ["1", "2", ""]
assert col.get_note(col.models.nids(m)[0]).fields == ["1", "2", ""]
# move 0 -> 2
col.models.moveField(m, m["flds"][0], 2)
assert col.getNote(col.models.nids(m)[0]).fields == ["2", "", "1"]
assert col.get_note(col.models.nids(m)[0]).fields == ["2", "", "1"]
# move 0 -> 1
col.models.moveField(m, m["flds"][0], 1)
assert col.getNote(col.models.nids(m)[0]).fields == ["", "2", "1"]
assert col.get_note(col.models.nids(m)[0]).fields == ["", "2", "1"]
def test_templates():

View File

@ -145,7 +145,7 @@ class AddCards(QDialog):
m = QMenu(self)
for nid in self.history:
if self.mw.col.findNotes(SearchNode(nid=nid)):
note = self.mw.col.getNote(nid)
note = self.mw.col.get_note(nid)
fields = note.fields
txt = htmlToTextLine(", ".join(fields))
if len(txt) > 30:

View File

@ -413,7 +413,7 @@ class StatusDelegate(QItemDelegate):
col = None
if c.userFlag() > 0:
col = getattr(colors, f"FLAG{c.userFlag()}_BG")
elif c.note().hasTag("Marked"):
elif c.note().has_tag("Marked"):
col = colors.MARKED_BG
elif c.queue == QUEUE_TYPE_SUSPENDED:
col = colors.SUSPENDED_BG
@ -1317,7 +1317,7 @@ where id in %s"""
self.remove_tags_from_selected_notes(tags="marked")
def isMarked(self) -> bool:
return bool(self.card and self.card.note().hasTag("Marked"))
return bool(self.card and self.card.note().has_tag("Marked"))
# Repositioning
######################################################################

View File

@ -506,7 +506,7 @@ class AnkiQt(QMainWindow):
# make sure we don't get into an inconsistent state if an add-on
# has broken the deck browser or the did_load hook
try:
self.maybeEnableUndo()
self.update_undo_actions()
gui_hooks.collection_did_load(self.col)
self.moveToState("deckBrowser")
except Exception as e:
@ -688,7 +688,7 @@ class AnkiQt(QMainWindow):
if not guiOnly:
self.col.reset()
gui_hooks.state_did_reset()
self.maybeEnableUndo()
self.update_undo_actions()
self.moveToState(self.state)
def requireReset(
@ -1032,7 +1032,7 @@ title="%s" %s>%s</button>""" % (
if result is None:
# should not happen
showInfo("nothing to undo")
self.maybeEnableUndo()
self.update_undo_actions()
return
elif isinstance(result, ReviewUndo):
@ -1070,9 +1070,11 @@ title="%s" %s>%s</button>""" % (
tooltip(tr(TR.UNDO_ACTION_UNDONE, action=name))
gui_hooks.state_did_revert(name)
self.maybeEnableUndo()
self.update_undo_actions()
def maybeEnableUndo(self) -> None:
def update_undo_actions(self) -> None:
"""Update menu text and enable/disable menu item as appropriate.
Plural as this may handle redo in the future too."""
if self.col:
status = self.col.undo_status()
undo_action = status.undo or None
@ -1091,11 +1093,13 @@ title="%s" %s>%s</button>""" % (
def checkpoint(self, name: str) -> None:
self.col.save(name)
self.maybeEnableUndo()
self.update_undo_actions()
def autosave(self) -> None:
self.col.autosave()
self.maybeEnableUndo()
self.update_undo_actions()
maybeEnableUndo = update_undo_actions
# Other menu operations
##########################################################################

View File

@ -204,8 +204,8 @@ class Reviewer:
bodyclass = theme_manager.body_classes_for_card_ord(c.ord)
self.web.eval(f"_showQuestion({json.dumps(q)},'{bodyclass}');")
self._drawFlag()
self._drawMark()
self._update_flag_icon()
self._update_mark_icon()
self._showAnswerButton()
self.mw.web.setFocus()
# user hook
@ -215,11 +215,14 @@ class Reviewer:
print("use card.autoplay() instead of reviewer.autoplay(card)")
return card.autoplay()
def _drawFlag(self) -> None:
def _update_flag_icon(self) -> None:
self.web.eval(f"_drawFlag({self.card.userFlag()});")
def _drawMark(self) -> None:
self.web.eval(f"_drawMark({json.dumps(self.card.note().hasTag('marked'))});")
def _update_mark_icon(self) -> None:
self.web.eval(f"_drawMark({json.dumps(self.card.note().has_tag('marked'))});")
_drawMark = _update_mark_icon
_drawFlag = _update_flag_icon
# Showing the answer
##########################################################################
@ -291,7 +294,7 @@ class Reviewer:
("Ctrl+2", lambda: self.setFlag(2)),
("Ctrl+3", lambda: self.setFlag(3)),
("Ctrl+4", lambda: self.setFlag(4)),
("*", self.onMark),
("*", self.toggle_mark_on_current_note),
("=", self.bury_current_note),
("-", self.bury_current_card),
("!", self.suspend_current_note),
@ -726,7 +729,7 @@ time = %(time)d;
],
],
],
[tr(TR.STUDYING_MARK_NOTE), "*", self.onMark],
[tr(TR.STUDYING_MARK_NOTE), "*", self.toggle_mark_on_current_note],
[tr(TR.STUDYING_BURY_CARD), "-", self.bury_current_card],
[tr(TR.STUDYING_BURY_NOTE), "=", self.bury_current_note],
[tr(TR.ACTIONS_SET_DUE_DATE), "Ctrl+Shift+D", self.on_set_due],
@ -785,16 +788,17 @@ time = %(time)d;
flag = 0
self.card.setUserFlag(flag)
self.card.flush()
self._drawFlag()
self._update_flag_icon()
def onMark(self) -> None:
f = self.card.note()
if f.hasTag("marked"):
f.delTag("marked")
def toggle_mark_on_current_note(self) -> None:
note = self.card.note()
if note.has_tag("marked"):
note.remove_tag("marked")
else:
f.addTag("marked")
f.flush()
self._drawMark()
note.add_tag("marked")
self.mw.col.update_note(note)
self._update_mark_icon()
self.mw.update_undo_actions()
def on_set_due(self) -> None:
if self.mw.state != "review" or not self.card:
@ -858,3 +862,4 @@ time = %(time)d;
onSuspend = suspend_current_note
onSuspendCard = suspend_current_card
onDelete = delete_current_note
onMark = toggle_mark_on_current_note

View File

@ -171,7 +171,7 @@ service BackendService {
rpc NewNote(NoteTypeID) returns (Note);
rpc AddNote(AddNoteIn) returns (NoteID);
rpc UpdateNote(Note) returns (Empty);
rpc UpdateNote(UpdateNoteIn) returns (Empty);
rpc GetNote(NoteID) returns (Note);
rpc RemoveNotes(RemoveNotesIn) returns (Empty);
rpc AddNoteTags(AddNoteTagsIn) returns (UInt32);
@ -948,6 +948,11 @@ message AddNoteIn {
int64 deck_id = 2;
}
message UpdateNoteIn {
Note note = 1;
bool skip_undo_entry = 2;
}
message EmptyCardsReport {
message NoteWithEmptyCards {
int64 note_id = 1;

View File

@ -50,6 +50,7 @@ use crate::{
text::{escape_anki_wildcards, extract_av_tags, sanitize_html_no_images, strip_av_tags, AVTag},
timestamp::TimestampSecs,
types::Usn,
undo::UndoableOp,
};
use fluent::FluentValue;
use futures::future::{AbortHandle, AbortRegistration, Abortable};
@ -1043,10 +1044,15 @@ impl BackendService for Backend {
})
}
fn update_note(&self, input: pb::Note) -> BackendResult<Empty> {
fn update_note(&self, input: pb::UpdateNoteIn) -> BackendResult<Empty> {
self.with_col(|col| {
let mut note: Note = input.into();
col.update_note(&mut note)
let op = if input.skip_undo_entry {
None
} else {
Some(UndoableOp::UpdateNote)
};
let mut note: Note = input.note.ok_or(AnkiError::NotFound)?.into();
col.update_note_with_op(&mut note, op)
})
.map(Into::into)
}

View File

@ -330,13 +330,21 @@ impl Collection {
}
pub fn update_note(&mut self, note: &mut Note) -> Result<()> {
self.update_note_with_op(note, Some(UndoableOp::UpdateNote))
}
pub(crate) fn update_note_with_op(
&mut self,
note: &mut Note,
op: Option<UndoableOp>,
) -> Result<()> {
let mut existing_note = self.storage.get_note(note.id)?.ok_or(AnkiError::NotFound)?;
if !note_modified(&mut existing_note, note) {
// nothing to do
return Ok(());
}
self.transact(None, |col| {
self.transact(op, |col| {
let nt = col
.get_notetype(note.notetype_id)?
.ok_or_else(|| AnkiError::invalid_input("missing note type"))?;

View File

@ -16,6 +16,7 @@ pub enum UndoableOp {
AddNote,
RemoveNote,
UpdateTag,
UpdateNote,
}
impl UndoableOp {
@ -35,6 +36,7 @@ impl Collection {
UndoableOp::AddNote => TR::UndoAddNote,
UndoableOp::RemoveNote => TR::StudyingDeleteNote,
UndoableOp::UpdateTag => TR::UndoUpdateTag,
UndoableOp::UpdateNote => TR::UndoUpdateNote,
};
self.i18n.tr(key).to_string()