make reposition undoable

This commit is contained in:
Damien Elmes 2021-03-18 11:46:11 +10:00
parent 846e7cd4aa
commit 0331d8b588
10 changed files with 155 additions and 141 deletions

View File

@ -146,3 +146,8 @@ browsing-removed-unused-tags-count =
[one] Removed { $count } unused tag. [one] Removed { $count } unused tag.
*[other] Removed { $count } unused tags. *[other] Removed { $count } unused tags.
} }
browsing-changed-new-position =
{ $count ->
[one] Changed position of { $count } new card.
*[other] Changed position of { $count } new cards.
}

View File

@ -5,7 +5,7 @@ from __future__ import annotations
import anki import anki
import anki._backend.backend_pb2 as _pb import anki._backend.backend_pb2 as _pb
from anki.collection import OpChanges from anki.collection import OpChanges, OpChangesWithCount
from anki.config import Config from anki.config import Config
SchedTimingToday = _pb.SchedTimingTodayOut SchedTimingToday = _pb.SchedTimingTodayOut
@ -167,20 +167,20 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# Repositioning new cards # Repositioning new cards
########################################################################## ##########################################################################
def sortCards( def reposition_new_cards(
self, self,
cids: List[int], card_ids: Sequence[int],
start: int = 1, starting_from: int,
step: int = 1, step_size: int,
shuffle: bool = False, randomize: bool,
shift: bool = False, shift_existing: bool,
) -> None: ) -> OpChangesWithCount:
self.col._backend.sort_cards( return self.col._backend.sort_cards(
card_ids=cids, card_ids=card_ids,
starting_from=start, starting_from=starting_from,
step_size=step, step_size=step_size,
randomize=shuffle, randomize=randomize,
shift_existing=shift, shift_existing=shift_existing,
) )
def randomizeCards(self, did: int) -> None: def randomizeCards(self, did: int) -> None:
@ -204,3 +204,14 @@ select id from cards where did in %s and queue = {QUEUE_TYPE_REV} and due <= ? l
# in order due? # in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM: if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did) self.randomizeCards(did)
# legacy
def sortCards(
self,
cids: List[int],
start: int = 1,
step: int = 1,
shuffle: bool = False,
shift: bool = False,
) -> None:
self.reposition_new_cards(cids, start, step, shuffle, shift)

View File

@ -1023,62 +1023,6 @@ def test_deckFlow():
col.sched.answerCard(c, 2) col.sched.answerCard(c, 2)
def test_reorder():
col = getEmptyCol()
# add a note with default deck
note = col.newNote()
note["Front"] = "one"
col.addNote(note)
note2 = col.newNote()
note2["Front"] = "two"
col.addNote(note2)
assert note2.cards()[0].due == 2
found = False
# 50/50 chance of being reordered
for i in range(20):
col.sched.randomizeCards(1)
if note.cards()[0].due != note.id:
found = True
break
assert found
col.sched.orderCards(1)
assert note.cards()[0].due == 1
# shifting
note3 = col.newNote()
note3["Front"] = "three"
col.addNote(note3)
note4 = col.newNote()
note4["Front"] = "four"
col.addNote(note4)
assert note.cards()[0].due == 1
assert note2.cards()[0].due == 2
assert note3.cards()[0].due == 3
assert note4.cards()[0].due == 4
col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True)
assert note.cards()[0].due == 3
assert note2.cards()[0].due == 4
assert note3.cards()[0].due == 1
assert note4.cards()[0].due == 2
def test_forget():
col = getEmptyCol()
note = col.newNote()
note["Front"] = "one"
col.addNote(note)
c = note.cards()[0]
c.queue = QUEUE_TYPE_REV
c.type = CARD_TYPE_REV
c.ivl = 100
c.due = 0
c.flush()
col.reset()
assert col.sched.counts() == (0, 0, 1)
col.sched.forgetCards([c.id])
col.reset()
assert col.sched.counts() == (1, 0, 0)
def test_norelearn(): def test_norelearn():
col = getEmptyCol() col = getEmptyCol()
# add a note # add a note

View File

@ -1211,7 +1211,13 @@ def test_reorder():
assert note2.cards()[0].due == 2 assert note2.cards()[0].due == 2
assert note3.cards()[0].due == 3 assert note3.cards()[0].due == 3
assert note4.cards()[0].due == 4 assert note4.cards()[0].due == 4
col.sched.sortCards([note3.cards()[0].id, note4.cards()[0].id], start=1, shift=True) col.sched.reposition_new_cards(
[note3.cards()[0].id, note4.cards()[0].id],
starting_from=1,
shift_existing=True,
step_size=1,
randomize=False,
)
assert note.cards()[0].due == 3 assert note.cards()[0].due == 3
assert note2.cards()[0].due == 4 assert note2.cards()[0].due == 4
assert note3.cards()[0].due == 1 assert note3.cards()[0].due == 1

View File

@ -37,6 +37,7 @@ from aqt.previewer import Previewer
from aqt.qt import * from aqt.qt import *
from aqt.scheduling_ops import ( from aqt.scheduling_ops import (
forget_cards, forget_cards,
reposition_new_cards_dialog,
set_due_date_dialog, set_due_date_dialog,
suspend_cards, suspend_cards,
unsuspend_cards, unsuspend_cards,
@ -247,7 +248,7 @@ class DataModel(QAbstractTableModel):
self.endReset() self.endReset()
def saveSelection(self) -> None: def saveSelection(self) -> None:
cards = self.browser.selectedCards() cards = self.browser.selected_cards()
self.selectedCards = {id: True for id in cards} self.selectedCards = {id: True for id in cards}
if getattr(self.browser, "card", None): if getattr(self.browser, "card", None):
self.focusedCard = self.browser.card.id self.focusedCard = self.browser.card.id
@ -1076,13 +1077,13 @@ QTableView {{ gridline-color: {grid} }}
# Menu helpers # Menu helpers
###################################################################### ######################################################################
def selectedCards(self) -> List[int]: def selected_cards(self) -> List[int]:
return [ return [
self.model.cards[idx.row()] self.model.cards[idx.row()]
for idx in self.form.tableView.selectionModel().selectedRows() for idx in self.form.tableView.selectionModel().selectedRows()
] ]
def selectedNotes(self) -> List[int]: def selected_notes(self) -> List[int]:
return self.col.db.list( return self.col.db.list(
""" """
select distinct nid from cards select distinct nid from cards
@ -1098,11 +1099,11 @@ where id in %s"""
def selectedNotesAsCards(self) -> List[int]: def selectedNotesAsCards(self) -> List[int]:
return self.col.db.list( return self.col.db.list(
"select id from cards where nid in (%s)" "select id from cards where nid in (%s)"
% ",".join([str(s) for s in self.selectedNotes()]) % ",".join([str(s) for s in self.selected_notes()])
) )
def oneModelNotes(self) -> List[int]: def oneModelNotes(self) -> List[int]:
sf = self.selectedNotes() sf = self.selected_notes()
if not sf: if not sf:
return [] return []
mods = self.col.db.scalar( mods = self.col.db.scalar(
@ -1119,6 +1120,11 @@ where id in %s"""
def onHelp(self) -> None: def onHelp(self) -> None:
openHelp(HelpPage.BROWSING) openHelp(HelpPage.BROWSING)
# legacy
selectedCards = selected_cards
selectedNotes = selected_notes
# Misc menu options # Misc menu options
###################################################################### ######################################################################
@ -1174,7 +1180,7 @@ where id in %s"""
return return
# nothing selected? # nothing selected?
nids = self.selectedNotes() nids = self.selected_notes()
if not nids: if not nids:
return return
@ -1198,7 +1204,7 @@ where id in %s"""
def set_deck_of_selected_cards(self) -> None: def set_deck_of_selected_cards(self) -> None:
from aqt.studydeck import StudyDeck from aqt.studydeck import StudyDeck
cids = self.selectedCards() cids = self.selected_cards()
if not cids: if not cids:
return return
@ -1235,7 +1241,7 @@ where id in %s"""
tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD)) tags := tags or self._prompt_for_tags(tr(TR.BROWSING_ENTER_TAGS_TO_ADD))
): ):
return return
add_tags(mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags) add_tags(mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags)
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None: def remove_tags_from_selected_notes(self, tags: Optional[str] = None) -> None:
@ -1245,7 +1251,7 @@ where id in %s"""
): ):
return return
remove_tags( remove_tags(
mw=self.mw, note_ids=self.selectedNotes(), space_separated_tags=tags mw=self.mw, note_ids=self.selected_notes(), space_separated_tags=tags
) )
def _prompt_for_tags(self, prompt: str) -> Optional[str]: def _prompt_for_tags(self, prompt: str) -> Optional[str]:
@ -1272,7 +1278,7 @@ where id in %s"""
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def suspend_selected_cards(self) -> None: def suspend_selected_cards(self) -> None:
want_suspend = not self.current_card_is_suspended() want_suspend = not self.current_card_is_suspended()
cids = self.selectedCards() cids = self.selected_cards()
if want_suspend: if want_suspend:
suspend_cards(mw=self.mw, card_ids=cids) suspend_cards(mw=self.mw, card_ids=cids)
@ -1300,7 +1306,7 @@ where id in %s"""
if flag == self.card.user_flag(): if flag == self.card.user_flag():
flag = 0 flag = 0
cids = self.selectedCards() cids = self.selected_cards()
set_card_flag(mw=self.mw, card_ids=cids, flag=flag) set_card_flag(mw=self.mw, card_ids=cids, flag=flag)
def _updateFlagsMenu(self) -> None: def _updateFlagsMenu(self) -> None:
@ -1331,57 +1337,25 @@ where id in %s"""
def isMarked(self) -> bool: def isMarked(self) -> bool:
return bool(self.card and self.card.note().has_tag("Marked")) return bool(self.card and self.card.note().has_tag("Marked"))
# Repositioning # Scheduling
###################################################################### ######################################################################
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def reposition(self) -> None: def reposition(self) -> None:
cids = self.selectedCards() if self.card and self.card.queue != QUEUE_TYPE_NEW:
cids2 = self.col.db.list( showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED), parent=self)
f"select id from cards where type = {CARD_TYPE_NEW} and id in "
+ ids2str(cids)
)
if not cids2:
showInfo(tr(TR.BROWSING_ONLY_NEW_CARDS_CAN_BE_REPOSITIONED))
return return
d = QDialog(self)
disable_help_button(d)
d.setWindowModality(Qt.WindowModal)
frm = aqt.forms.reposition.Ui_Dialog()
frm.setupUi(d)
(pmin, pmax) = self.col.db.first(
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
)
pmin = pmin or 0
pmax = pmax or 0
txt = tr(TR.BROWSING_QUEUE_TOP, val=pmin)
txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=pmax)
frm.label.setText(txt)
frm.start.selectAll()
if not d.exec_():
return
self.model.beginReset()
self.mw.checkpoint(tr(TR.ACTIONS_REPOSITION))
self.col.sched.sortCards(
cids,
start=frm.start.value(),
step=frm.step.value(),
shuffle=frm.randomize.isChecked(),
shift=frm.shift.isChecked(),
)
self.search()
self.mw.requireReset(reason=ResetReason.BrowserReposition, context=self)
self.model.endReset()
# Scheduling reposition_new_cards_dialog(
###################################################################### mw=self.mw, parent=self, card_ids=self.selected_cards()
)
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def set_due_date(self) -> None: def set_due_date(self) -> None:
set_due_date_dialog( set_due_date_dialog(
mw=self.mw, mw=self.mw,
parent=self, parent=self,
card_ids=self.selectedCards(), card_ids=self.selected_cards(),
config_key=Config.String.SET_DUE_BROWSER, config_key=Config.String.SET_DUE_BROWSER,
) )
@ -1390,7 +1364,7 @@ where id in %s"""
forget_cards( forget_cards(
mw=self.mw, mw=self.mw,
parent=self, parent=self,
card_ids=self.selectedCards(), card_ids=self.selected_cards(),
) )
# Edit: selection # Edit: selection
@ -1398,7 +1372,7 @@ where id in %s"""
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def selectNotes(self) -> None: def selectNotes(self) -> None:
nids = self.selectedNotes() nids = self.selected_notes()
# clear the selection so we don't waste energy preserving it # clear the selection so we don't waste energy preserving it
tv = self.form.tableView tv = self.form.tableView
tv.selectionModel().clear() tv.selectionModel().clear()
@ -1465,7 +1439,7 @@ where id in %s"""
@ensure_editor_saved_on_trigger @ensure_editor_saved_on_trigger
def onFindReplace(self) -> None: def onFindReplace(self) -> None:
nids = self.selectedNotes() nids = self.selected_notes()
if not nids: if not nids:
return return
import anki.find import anki.find

View File

@ -6,12 +6,12 @@ from __future__ import annotations
from typing import List, Optional, Sequence from typing import List, Optional, Sequence
import aqt import aqt
from anki.collection import Config from anki.collection import CARD_TYPE_NEW, Config
from anki.lang import TR from anki.lang import TR
from aqt import AnkiQt from aqt import AnkiQt
from aqt.main import PerformOpOptionalSuccessCallback from aqt.main import PerformOpOptionalSuccessCallback
from aqt.qt import * from aqt.qt import *
from aqt.utils import getText, tooltip, tr from aqt.utils import disable_help_button, getText, tooltip, tr
def set_due_date_dialog( def set_due_date_dialog(
@ -63,6 +63,72 @@ def forget_cards(*, mw: aqt.AnkiQt, parent: QWidget, card_ids: List[int]) -> Non
) )
def reposition_new_cards_dialog(
*, mw: AnkiQt, parent: QWidget, card_ids: Sequence[int]
) -> None:
assert mw.col.db
row = mw.col.db.first(
f"select min(due), max(due) from cards where type={CARD_TYPE_NEW} and odid=0"
)
assert row
(min_position, max_position) = row
min_position = max(min_position or 0, 0)
max_position = max_position or 0
d = QDialog(parent)
disable_help_button(d)
d.setWindowModality(Qt.WindowModal)
frm = aqt.forms.reposition.Ui_Dialog()
frm.setupUi(d)
txt = tr(TR.BROWSING_QUEUE_TOP, val=min_position)
txt += "\n" + tr(TR.BROWSING_QUEUE_BOTTOM, val=max_position)
frm.label.setText(txt)
frm.start.selectAll()
if not d.exec_():
return
start = frm.start.value()
step = frm.step.value()
randomize = frm.randomize.isChecked()
shift = frm.shift.isChecked()
reposition_new_cards(
mw=mw,
parent=parent,
card_ids=card_ids,
starting_from=start,
step_size=step,
randomize=randomize,
shift_existing=shift,
)
def reposition_new_cards(
*,
mw: AnkiQt,
parent: QWidget,
card_ids: Sequence[int],
starting_from: int,
step_size: int,
randomize: bool,
shift_existing: bool,
) -> None:
mw.perform_op(
lambda: mw.col.sched.reposition_new_cards(
card_ids=card_ids,
starting_from=starting_from,
step_size=step_size,
randomize=randomize,
shift_existing=shift_existing,
),
success=lambda out: tooltip(
tr(TR.BROWSING_CHANGED_NEW_POSITION, count=out.count), parent=parent
),
)
def suspend_cards( def suspend_cards(
*, *,
mw: AnkiQt, mw: AnkiQt,

View File

@ -120,8 +120,8 @@ service SchedulingService {
rpc RebuildFilteredDeck(DeckID) returns (UInt32); rpc RebuildFilteredDeck(DeckID) returns (UInt32);
rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (OpChanges);
rpc SetDueDate(SetDueDateIn) returns (OpChanges); rpc SetDueDate(SetDueDateIn) returns (OpChanges);
rpc SortCards(SortCardsIn) returns (Empty); rpc SortCards(SortCardsIn) returns (OpChangesWithCount);
rpc SortDeck(SortDeckIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (OpChangesWithCount);
rpc GetNextCardStates(CardID) returns (NextCardStates); rpc GetNextCardStates(CardID) returns (NextCardStates);
rpc DescribeNextStates(NextCardStates) returns (StringList); rpc DescribeNextStates(NextCardStates) returns (StringList);
rpc StateIsLeech(SchedulingState) returns (Bool); rpc StateIsLeech(SchedulingState) returns (Bool);

View File

@ -118,7 +118,7 @@ impl SchedulingService for Backend {
self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into)) self.with_col(|col| col.set_due_date(&cids, &days, config).map(Into::into))
} }
fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::Empty> { fn sort_cards(&self, input: pb::SortCardsIn) -> Result<pb::OpChangesWithCount> {
let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect();
let (start, step, random, shift) = ( let (start, step, random, shift) = (
input.starting_from, input.starting_from,
@ -137,7 +137,7 @@ impl SchedulingService for Backend {
}) })
} }
fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::Empty> { fn sort_deck(&self, input: pb::SortDeckIn) -> Result<pb::OpChangesWithCount> {
self.with_col(|col| { self.with_col(|col| {
col.sort_deck(input.deck_id.into(), input.randomize) col.sort_deck(input.deck_id.into(), input.randomize)
.map(Into::into) .map(Into::into)

View File

@ -18,6 +18,7 @@ pub enum Op {
SetDeck, SetDeck,
SetDueDate, SetDueDate,
SetFlag, SetFlag,
SortCards,
Suspend, Suspend,
UnburyUnsuspend, UnburyUnsuspend,
UpdateCard, UpdateCard,
@ -50,6 +51,7 @@ impl Op {
Op::SetFlag => TR::UndoSetFlag, Op::SetFlag => TR::UndoSetFlag,
Op::FindAndReplace => TR::BrowsingFindAndReplace, Op::FindAndReplace => TR::BrowsingFindAndReplace,
Op::ClearUnusedTags => TR::BrowsingClearUnusedTags, Op::ClearUnusedTags => TR::BrowsingClearUnusedTags,
Op::SortCards => TR::BrowsingReschedule,
}; };
i18n.tr(key).to_string() i18n.tr(key).to_string()

View File

@ -24,12 +24,14 @@ impl Card {
self.ease_factor = 0; self.ease_factor = 0;
} }
/// If the card is new, change its position. /// If the card is new, change its position, and return true.
fn set_new_position(&mut self, position: u32) { fn set_new_position(&mut self, position: u32) -> bool {
if self.queue != CardQueue::New || self.ctype != CardType::New { if self.queue != CardQueue::New || self.ctype != CardType::New {
return; false
} } else {
self.due = position as i32; self.due = position as i32;
true
}
} }
} }
pub(crate) struct NewCardSorter { pub(crate) struct NewCardSorter {
@ -130,9 +132,9 @@ impl Collection {
step: u32, step: u32,
order: NewCardSortOrder, order: NewCardSortOrder,
shift: bool, shift: bool,
) -> Result<()> { ) -> Result<OpOutput<usize>> {
let usn = self.usn()?; let usn = self.usn()?;
self.transact_no_undo(|col| { self.transact(Op::SortCards, |col| {
col.sort_cards_inner(cids, starting_from, step, order, shift, usn) col.sort_cards_inner(cids, starting_from, step, order, shift, usn)
}) })
} }
@ -145,24 +147,28 @@ impl Collection {
order: NewCardSortOrder, order: NewCardSortOrder,
shift: bool, shift: bool,
usn: Usn, usn: Usn,
) -> Result<()> { ) -> Result<usize> {
if shift { if shift {
self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?; self.shift_existing_cards(starting_from, step * cids.len() as u32, usn)?;
} }
self.storage.set_search_table_to_card_ids(cids, true)?; self.storage.set_search_table_to_card_ids(cids, true)?;
let cards = self.storage.all_searched_cards_in_search_order()?; let cards = self.storage.all_searched_cards_in_search_order()?;
let sorter = NewCardSorter::new(&cards, starting_from, step, order); let sorter = NewCardSorter::new(&cards, starting_from, step, order);
let mut count = 0;
for mut card in cards { for mut card in cards {
let original = card.clone(); let original = card.clone();
card.set_new_position(sorter.position(&card)); if card.set_new_position(sorter.position(&card)) {
count += 1;
self.update_card_inner(&mut card, original, usn)?; self.update_card_inner(&mut card, original, usn)?;
} }
self.storage.clear_searched_cards_table() }
self.storage.clear_searched_cards_table()?;
Ok(count)
} }
/// This creates a transaction - we probably want to split it out /// This creates a transaction - we probably want to split it out
/// in the future if calling it as part of a deck options update. /// in the future if calling it as part of a deck options update.
pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<()> { pub fn sort_deck(&mut self, deck: DeckID, random: bool) -> Result<OpOutput<usize>> {
let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?; let cids = self.search_cards(&format!("did:{} is:new", deck), SortMode::NoOrder)?;
let order = if random { let order = if random {
NewCardSortOrder::Random NewCardSortOrder::Random