diff --git a/ftl/core/actions.ftl b/ftl/core/actions.ftl index c8da2aa95..392ee383a 100644 --- a/ftl/core/actions.ftl +++ b/ftl/core/actions.ftl @@ -33,3 +33,4 @@ actions-search = Search actions-shortcut-key = Shortcut key: { $val } actions-suspend-card = Suspend Card actions-set-due-date = Set Due Date +actions-forget = Forget diff --git a/ftl/core/browsing.ftl b/ftl/core/browsing.ftl index 4050fd634..bb82a0d74 100644 --- a/ftl/core/browsing.ftl +++ b/ftl/core/browsing.ftl @@ -66,8 +66,6 @@ browsing-optional-filter = Optional filter: browsing-override-back-template = Override back template: browsing-override-font = Override font: browsing-override-front-template = Override front template: -browsing-place-at-end-of-new-card = Place at end of new card queue -browsing-place-in-review-queue-with-interval = Place in review queue with interval between: browsing-please-give-your-filter-a-name = Please give your filter a name: browsing-please-select-cards-from-only-one = Please select cards from only one note type. browsing-preview-selected-card = Preview Selected Card ({ $val }) diff --git a/ftl/core/scheduling.ftl b/ftl/core/scheduling.ftl index 861c365e8..453246bca 100644 --- a/ftl/core/scheduling.ftl +++ b/ftl/core/scheduling.ftl @@ -143,3 +143,19 @@ scheduling-deck-updated = [one] { $count } deck updated. *[other] { $count } decks updated. } +scheduling-set-due-date-prompt = + { $cards -> + [one] Show card in how many days? + *[other] Show cards in how many days? (eg 1, or 1..7) + } +scheduling-set-due-date-changed-cards = + { $cards -> + [one] Changed card's due date. + *[other] Changed due date of { $cards } cards. + } +scheduling-set-due-date-invalid-input = Expected a number or range (eg 1, or 1..7) +scheduling-forgot-cards = + { $cards -> + [one] { $cards } card placed at the end of the new card queue. + *[other] { $cards } cards placed at the end of the new card queue. + } diff --git a/ftl/qt/qt-accel.ftl b/ftl/qt/qt-accel.ftl index 513aa4b02..db7346212 100644 --- a/ftl/qt/qt-accel.ftl +++ b/ftl/qt/qt-accel.ftl @@ -24,10 +24,11 @@ qt-accel-notes = &Notes qt-accel-open-addons-folder = &Open Add-ons Folder... qt-accel-preferences = &Preferences... qt-accel-previous-card = &Previous Card -qt-accel-reschedule = &Reschedule... qt-accel-select-all = Select &All qt-accel-select-notes = Select &Notes qt-accel-support-anki = &Support Anki... qt-accel-switch-profile = &Switch Profile qt-accel-tools = &Tools qt-accel-undo = &Undo +qt-accel-set-due-date = Set &Due Date... +qt-accel-forget = &Forget diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py index 0e6620ae1..06f5d4748 100644 --- a/pylib/anki/schedv2.py +++ b/pylib/anki/schedv2.py @@ -1397,20 +1397,17 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", if toBury: self.bury_cards(toBury, manual=False) - # Resetting + # Resetting/rescheduling ########################################################################## def schedule_cards_as_new(self, card_ids: List[int]) -> None: "Put cards at the end of the new queue." self.col._backend.schedule_cards_as_new(card_ids=card_ids, log=True) - def schedule_cards_as_reviews( - self, card_ids: List[int], min_interval: int, max_interval: int - ) -> None: - "Make cards review cards, with a new interval randomly selected from range." - self.col._backend.schedule_cards_as_reviews( - card_ids=card_ids, min_interval=min_interval, max_interval=max_interval - ) + def set_due_date(self, card_ids: List[int], days: str) -> None: + """Set cards to be due in `days`, turning them into review cards if necessary. + `days` can be of the form '5' or '5..7'""" + self.col._backend.set_due_date(card_ids=card_ids, days=days) def resetCards(self, ids: List[int]) -> None: "Completely reset cards for export." @@ -1430,8 +1427,12 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""", # legacy + def reschedCards( + self, card_ids: List[int], min_interval: int, max_interval: int + ) -> None: + self.set_due_date(card_ids, f"{min_interval}..{max_interval}") + forgetCards = schedule_cards_as_new - reschedCards = schedule_cards_as_reviews # Repositioning new cards ########################################################################## diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index ed9ca9ba3..a953fafd9 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -1077,23 +1077,6 @@ def test_forget(): assert col.sched.counts() == (1, 0, 0) -def test_resched(): - col = getEmptyCol() - note = col.newNote() - note["Front"] = "one" - col.addNote(note) - c = note.cards()[0] - col.sched.reschedCards([c.id], 0, 0) - c.load() - assert c.due == col.sched.today - assert c.ivl == 1 - assert c.queue == CARD_TYPE_REV and c.type == QUEUE_TYPE_REV - col.sched.reschedCards([c.id], 1, 1) - c.load() - assert c.due == col.sched.today + 1 - assert c.ivl == +1 - - def test_norelearn(): col = getEmptyCol() # add a note diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 42ddb5c1a..45a8997b7 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -1124,10 +1124,17 @@ def test_resched(): assert c.due == col.sched.today assert c.ivl == 1 assert c.queue == QUEUE_TYPE_REV and c.type == CARD_TYPE_REV + # make it due tomorrow, which increases its interval by a day col.sched.reschedCards([c.id], 1, 1) c.load() assert c.due == col.sched.today + 1 - assert c.ivl == +1 + assert c.ivl == 2 + # but if it was new, that would not happen + col.sched.forgetCards([c.id]) + col.sched.reschedCards([c.id], 1, 1) + c.load() + assert c.due == col.sched.today + 1 + assert c.ivl == 1 def test_norelearn(): diff --git a/qt/aqt/browser.py b/qt/aqt/browser.py index 96ea760c9..391c65eb2 100644 --- a/qt/aqt/browser.py +++ b/qt/aqt/browser.py @@ -28,6 +28,7 @@ from aqt.main import ResetReason from aqt.previewer import BrowserPreviewer as PreviewDialog from aqt.previewer import Previewer from aqt.qt import * +from aqt.scheduling import forget_cards, set_due_date_dialog from aqt.sidebar import SidebarSearchBar, SidebarTreeView from aqt.theme import theme_manager from aqt.utils import ( @@ -503,7 +504,8 @@ class Browser(QMainWindow): qconnect(f.actionChange_Deck.triggered, self.setDeck) qconnect(f.action_Info.triggered, self.showCardInfo) qconnect(f.actionReposition.triggered, self.reposition) - qconnect(f.actionReschedule.triggered, self.reschedule) + qconnect(f.action_set_due_date.triggered, self.set_due_date) + qconnect(f.action_forget.triggered, self.forget_cards) qconnect(f.actionToggle_Suspend.triggered, self.onSuspend) qconnect(f.actionRed_Flag.triggered, lambda: self.onSetFlag(1)) qconnect(f.actionOrange_Flag.triggered, lambda: self.onSetFlag(2)) @@ -1384,32 +1386,33 @@ where id in %s""" self.mw.requireReset(reason=ResetReason.BrowserReposition, context=self) self.model.endReset() - # Rescheduling + # Scheduling ###################################################################### - def reschedule(self) -> None: - self.editor.saveNow(self._reschedule) - - def _reschedule(self) -> None: - d = QDialog(self) - disable_help_button(d) - d.setWindowModality(Qt.WindowModal) - frm = aqt.forms.reschedule.Ui_Dialog() - frm.setupUi(d) - if not d.exec_(): - return - self.model.beginReset() - self.mw.checkpoint(tr(TR.BROWSING_RESCHEDULE)) - if frm.asNew.isChecked(): - self.col.sched.forgetCards(self.selectedCards()) - else: - fmin = frm.min.value() - fmax = frm.max.value() - fmax = max(fmin, fmax) - self.col.sched.reschedCards(self.selectedCards(), fmin, fmax) - self.search() + def _after_schedule(self) -> None: + self.model.reset() self.mw.requireReset(reason=ResetReason.BrowserReschedule, context=self) - self.model.endReset() + + def set_due_date(self) -> None: + self.editor.saveNow( + lambda: set_due_date_dialog( + mw=self.mw, + parent=self, + card_ids=self.selectedCards(), + default="0", + on_done=self._after_schedule, + ) + ) + + def forget_cards(self) -> None: + self.editor.saveNow( + lambda: forget_cards( + mw=self.mw, + parent=self, + card_ids=self.selectedCards(), + on_done=self._after_schedule, + ) + ) # Edit: selection ###################################################################### diff --git a/qt/aqt/forms/__init__.py b/qt/aqt/forms/__init__.py index 0a38598dc..497228594 100644 --- a/qt/aqt/forms/__init__.py +++ b/qt/aqt/forms/__init__.py @@ -32,7 +32,6 @@ from . import preview from . import profiles from . import progress from . import reposition -from . import reschedule from . import setgroup from . import setlang from . import stats diff --git a/qt/aqt/forms/browser.ui b/qt/aqt/forms/browser.ui index ffed2649f..91482abbe 100644 --- a/qt/aqt/forms/browser.ui +++ b/qt/aqt/forms/browser.ui @@ -262,7 +262,8 @@ - + + @@ -298,14 +299,6 @@ - - - QT_ACCEL_RESCHEDULE - - - Ctrl+Alt+R - - QT_ACCEL_SELECT_ALL @@ -465,7 +458,7 @@ BROWSING_REMOVE_TAGS - Ctrl+Shift+D + Ctrl+Alt+Shift+A @@ -586,6 +579,19 @@ Ctrl+G + + + QT_ACCEL_SET_DUE_DATE + + + Ctrl+Shift+D + + + + + QT_ACCEL_FORGET + + diff --git a/qt/aqt/forms/build_ui.py b/qt/aqt/forms/build_ui.py index 3b61ddb09..75284ee17 100644 --- a/qt/aqt/forms/build_ui.py +++ b/qt/aqt/forms/build_ui.py @@ -18,33 +18,3 @@ outdata = re.sub( with open(py_file, "w") as file: file.write(outdata) - -# init=aqt/forms/__init__.py -# temp=aqt/forms/scratch -# rm -f $init $temp -# echo "# This file auto-generated by build_ui.sh. Don't edit." > $init -# echo "__all__ = [" >> $init - -# echo "Generating forms.." -# for i in designer/*.ui -# do -# base=$(basename $i .ui) -# py="aqt/forms/${base}.py" -# echo " \"$base\"," >> $init -# echo "from . import $base" >> $temp -# if [ $i -nt $py ]; then -# echo " * "$py -# pyuic5 --from-imports $i -o $py.tmp -# (cat < $py -# # -*- coding: utf-8 -*- -# # pylint: disable=unsubscriptable-object,unused-import -# # EOF -# rm $py.tmp -# fi -# done -# echo "]" >> $init -# cat $temp >> $init -# rm $temp - -# echo "Building resources.." -# pyrcc5 designer/icons.qrc -o aqt/forms/icons_rc.py \ No newline at end of file diff --git a/qt/aqt/forms/reschedule.ui b/qt/aqt/forms/reschedule.ui deleted file mode 100644 index e67d4b67e..000000000 --- a/qt/aqt/forms/reschedule.ui +++ /dev/null @@ -1,183 +0,0 @@ - - - Dialog - - - - 0 - 0 - 325 - 144 - - - - BROWSING_RESCHEDULE - - - - - - BROWSING_PLACE_AT_END_OF_NEW_CARD - - - true - - - - - - - BROWSING_PLACE_IN_REVIEW_QUEUE_WITH_INTERVAL - - - - - - - false - - - - 20 - - - 0 - - - 0 - - - 0 - - - - - - - ~ - - - - - - - 9999 - - - - - - - 9999 - - - - - - - SCHEDULING_DAYS - - - - - - - Qt::Horizontal - - - - 40 - 20 - - - - - - - - - - - - - Qt::Vertical - - - - 20 - 40 - - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Ok - - - - - - - asNew - asRev - min - max - buttonBox - - - - - buttonBox - accepted() - Dialog - accept() - - - 222 - 144 - - - 157 - 157 - - - - - buttonBox - rejected() - Dialog - reject() - - - 222 - 150 - - - 226 - 157 - - - - - asRev - toggled(bool) - rangebox - setEnabled(bool) - - - 30 - 40 - - - 11 - 79 - - - - - diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 9424eeb8b..dc2b71a15 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -18,6 +18,7 @@ from anki.utils import stripHTML from aqt import AnkiQt, gui_hooks from aqt.profiles import VideoDriver from aqt.qt import * +from aqt.scheduling import set_due_date_dialog from aqt.sound import av_player, play_clicked_audio, record_audio from aqt.theme import theme_manager from aqt.toolbar import BottomBar @@ -300,6 +301,7 @@ class Reviewer: ("!", self.onSuspend), ("@", self.onSuspendCard), ("Ctrl+Delete", self.onDelete), + ("Ctrl+Shift+D", self.on_set_due), ("v", self.onReplayRecorded), ("Shift+v", self.onRecordVoice), ("o", self.onOptions), @@ -732,6 +734,7 @@ time = %(time)d; [tr(TR.STUDYING_MARK_NOTE), "*", self.onMark], [tr(TR.STUDYING_BURY_CARD), "-", self.onBuryCard], [tr(TR.STUDYING_BURY_NOTE), "=", self.onBuryNote], + [tr(TR.ACTIONS_SET_DUE_DATE), "Ctrl+Shift+D", self.on_set_due], [tr(TR.ACTIONS_SUSPEND_CARD), "@", self.onSuspendCard], [tr(TR.STUDYING_SUSPEND_NOTE), "!", self.onSuspend], [tr(TR.STUDYING_DELETE_NOTE), "Ctrl+Delete", self.onDelete], @@ -798,6 +801,18 @@ time = %(time)d; f.flush() self._drawMark() + def on_set_due(self) -> None: + if self.mw.state != "review" or not self.card: + return + + set_due_date_dialog( + mw=self.mw, + parent=self.mw, + card_ids=[self.card.id], + default="1", + on_done=self.mw.reset, + ) + def onSuspend(self) -> None: self.mw.checkpoint(tr(TR.STUDYING_SUSPEND)) self.mw.col.sched.suspend_cards([c.id for c in self.card.note().cards()]) diff --git a/qt/aqt/scheduling.py b/qt/aqt/scheduling.py new file mode 100644 index 000000000..7c57bcd05 --- /dev/null +++ b/qt/aqt/scheduling.py @@ -0,0 +1,80 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +from concurrent.futures import Future +from typing import List + +import aqt +from anki.errors import InvalidInput +from anki.lang import TR +from aqt.qt import * +from aqt.utils import getText, showWarning, tooltip, tr + + +def set_due_date_dialog( + *, + mw: aqt.AnkiQt, + parent: QDialog, + card_ids: List[int], + default: str, + on_done: Callable[[], None], +) -> None: + if not card_ids: + return + + (days, success) = getText( + prompt=tr(TR.SCHEDULING_SET_DUE_DATE_PROMPT, cards=len(card_ids)), + parent=parent, + default=default, + title=tr(TR.ACTIONS_SET_DUE_DATE), + ) + if not success or not days.strip(): + return + + def on_done_wrapper(fut: Future) -> None: + try: + fut.result() + except Exception as e: + if isinstance(e, InvalidInput): + err = tr(TR.SCHEDULING_SET_DUE_DATE_INVALID_INPUT) + else: + err = str(e) + showWarning(err) + on_done() + return + + tooltip( + tr(TR.SCHEDULING_SET_DUE_DATE_CHANGED_CARDS, cards=len(card_ids)), + parent=parent, + ) + + on_done() + + mw.checkpoint(tr(TR.ACTIONS_SET_DUE_DATE)) + mw.taskman.with_progress( + lambda: mw.col.sched.set_due_date(card_ids, days), on_done_wrapper + ) + + +def forget_cards( + *, mw: aqt.AnkiQt, parent: QDialog, card_ids: List[int], on_done: Callable[[], None] +) -> None: + if not card_ids: + return + + def on_done_wrapper(fut: Future) -> None: + try: + fut.result() + except Exception as e: + showWarning(str(e)) + else: + tooltip(tr(TR.SCHEDULING_FORGOT_CARDS, cards=len(card_ids)), parent=parent) + + on_done() + + mw.checkpoint(tr(TR.ACTIONS_FORGET)) + mw.taskman.with_progress( + lambda: mw.col.sched.schedule_cards_as_new(card_ids), on_done_wrapper + ) diff --git a/qt/aqt/utils.py b/qt/aqt/utils.py index 6eb822559..a0d31ca8d 100644 --- a/qt/aqt/utils.py +++ b/qt/aqt/utils.py @@ -397,6 +397,7 @@ def getText( geomKey: Optional[str] = None, **kwargs: Any, ) -> Tuple[str, int]: + "Returns (string, succeeded)." if not parent: parent = aqt.mw.app.activeWindow() or aqt.mw d = GetTextDialog( diff --git a/rslib/backend.proto b/rslib/backend.proto index 25ceddd45..513e2b2d1 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -113,8 +113,8 @@ service BackendService { rpc BuryOrSuspendCards(BuryOrSuspendCardsIn) returns (Empty); rpc EmptyFilteredDeck(DeckID) returns (Empty); rpc RebuildFilteredDeck(DeckID) returns (UInt32); - rpc ScheduleCardsAsReviews(ScheduleCardsAsReviewsIn) returns (Empty); rpc ScheduleCardsAsNew(ScheduleCardsAsNewIn) returns (Empty); + rpc SetDueDate(SetDueDateIn) returns (Empty); rpc SortCards(SortCardsIn) returns (Empty); rpc SortDeck(SortDeckIn) returns (Empty); @@ -1188,17 +1188,16 @@ message BuryOrSuspendCardsIn { Mode mode = 2; } -message ScheduleCardsAsReviewsIn { - repeated int64 card_ids = 1; - uint32 min_interval = 2; - uint32 max_interval = 3; -} - message ScheduleCardsAsNewIn { repeated int64 card_ids = 1; bool log = 2; } +message SetDueDateIn { + repeated int64 card_ids = 1; + string days = 2; +} + message SortCardsIn { repeated int64 card_ids = 1; uint32 starting_from = 2; diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index e638f7d27..be87953fd 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -31,8 +31,11 @@ use crate::{ all_stock_notetypes, CardTemplateSchema11, NoteType, NoteTypeID, NoteTypeSchema11, RenderCardOutput, }, - sched::new::NewCardSortOrder, - sched::timespan::{answer_button_time, time_span}, + sched::{ + new::NewCardSortOrder, + parse_due_date_str, + timespan::{answer_button_time, time_span}, + }, search::{ concatenate_searches, negate_search, normalize_search, replace_search_term, write_nodes, BoolSeparator, Node, PropertyKind, RatingKind, SearchNode, SortMode, StateKind, @@ -161,6 +164,7 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError { AnkiError::DeckIsFiltered => V::DeckIsFiltered(Empty {}), AnkiError::SearchError(_) => V::InvalidInput(pb::Empty {}), AnkiError::TemplateSaveError { .. } => V::TemplateParse(pb::Empty {}), + AnkiError::ParseNumError => V::InvalidInput(pb::Empty {}), }; pb::BackendError { @@ -660,18 +664,6 @@ impl BackendService for Backend { self.with_col(|col| col.rebuild_filtered_deck(input.did.into()).map(Into::into)) } - fn schedule_cards_as_reviews( - &self, - input: pb::ScheduleCardsAsReviewsIn, - ) -> BackendResult { - let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); - let (min, max) = (input.min_interval, input.max_interval); - self.with_col(|col| { - col.reschedule_cards_as_reviews(&cids, min, max) - .map(Into::into) - }) - } - fn schedule_cards_as_new(&self, input: pb::ScheduleCardsAsNewIn) -> BackendResult { self.with_col(|col| { let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); @@ -680,6 +672,12 @@ impl BackendService for Backend { }) } + fn set_due_date(&self, input: pb::SetDueDateIn) -> BackendResult { + let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); + let (min, max) = parse_due_date_str(&input.days)?; + self.with_col(|col| col.set_due_date(&cids, min, max).map(Into::into)) + } + fn sort_cards(&self, input: pb::SortCardsIn) -> BackendResult { let cids: Vec<_> = input.card_ids.into_iter().map(CardID).collect(); let (start, step, random, shift) = ( diff --git a/rslib/src/err.rs b/rslib/src/err.rs index bca42c1b5..886270eda 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -5,7 +5,7 @@ use crate::i18n::{tr_args, tr_strs, I18n, TR}; pub use failure::{Error, Fail}; use nom::error::{ErrorKind as NomErrorKind, ParseError as NomParseError}; use reqwest::StatusCode; -use std::{io, str::Utf8Error}; +use std::{io, num::ParseIntError, str::Utf8Error}; use tempfile::PathPersistError; pub type Result = std::result::Result; @@ -42,6 +42,9 @@ pub enum AnkiError { #[fail(display = "Protobuf encode/decode error: {}", info)] ProtoError { info: String }, + #[fail(display = "Unable to parse number")] + ParseNumError, + #[fail(display = "The user interrupted the operation.")] Interrupted, @@ -467,3 +470,9 @@ impl<'a> NomParseError<&'a str> for ParseError<'a> { other } } + +impl From for AnkiError { + fn from(_err: ParseIntError) -> Self { + AnkiError::ParseNumError + } +} diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs index 36b0967c3..c07b4e9f3 100644 --- a/rslib/src/filtered.rs +++ b/rslib/src/filtered.rs @@ -64,6 +64,17 @@ impl Card { } } + /// Returns original_due if set, else due. + /// original_due will be set in filtered decks, and in relearning in + /// the old scheduler. + pub(crate) fn original_or_current_due(&self) -> i32 { + if self.original_due > 0 { + self.original_due + } else { + self.due + } + } + pub(crate) fn remove_from_filtered_deck_restoring_queue(&mut self, sched: SchedulerVersion) { if self.original_deck_id.0 == 0 { // not in a filtered deck diff --git a/rslib/src/revlog.rs b/rslib/src/revlog.rs index 71ab56a14..2802a1fed 100644 --- a/rslib/src/revlog.rs +++ b/rslib/src/revlog.rs @@ -61,35 +61,20 @@ impl RevlogEntry { } } -impl Card { - fn last_interval_for_revlog_todo(&self) -> i32 { - self.interval as i32 - - // fixme: need to pass in delays for (re)learning - // if let Some(delay) = self.current_learning_delay_seconds(&[]) { - // -(delay as i32) - // } else { - // self.interval as i32 - // } - } -} - impl Collection { pub(crate) fn log_manually_scheduled_review( &mut self, card: &Card, + original: &Card, usn: Usn, - next_interval: u32, ) -> Result<()> { - println!("fixme: learning last_interval"); - // let deck = self.get_deck(card.deck_id)?.ok_or(AnkiError::NotFound)?; let entry = RevlogEntry { id: TimestampMillis::now(), cid: card.id, usn, button_chosen: 0, - interval: next_interval as i32, - last_interval: card.last_interval_for_revlog_todo(), + interval: card.interval as i32, + last_interval: original.interval as i32, ease_factor: card.ease_factor as u32, taken_millis: 0, review_kind: RevlogReviewKind::Manual, diff --git a/rslib/src/sched/bury_and_suspend.rs b/rslib/src/sched/bury_and_suspend.rs index 3c0f985e1..9ebfb5952 100644 --- a/rslib/src/sched/bury_and_suspend.rs +++ b/rslib/src/sched/bury_and_suspend.rs @@ -24,12 +24,7 @@ impl Card { } else { self.queue = match self.ctype { CardType::Learn | CardType::Relearn => { - let original_due = if self.original_due > 0 { - self.original_due - } else { - self.due - }; - if original_due > 1_000_000_000 { + if self.original_or_current_due() > 1_000_000_000 { // previous interval was in seconds CardQueue::Learn } else { diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs index 4bf5e64fe..464ba39db 100644 --- a/rslib/src/sched/mod.rs +++ b/rslib/src/sched/mod.rs @@ -16,6 +16,7 @@ use cutoff::{ sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp, SchedTimingToday, }; +pub use reviews::parse_due_date_str; impl Collection { pub fn timing_today(&self) -> Result { diff --git a/rslib/src/sched/new.rs b/rslib/src/sched/new.rs index b19b9eacf..16d8f215b 100644 --- a/rslib/src/sched/new.rs +++ b/rslib/src/sched/new.rs @@ -110,10 +110,10 @@ impl Collection { let cards = col.storage.all_searched_cards_in_search_order()?; for mut card in cards { let original = card.clone(); - if log { - col.log_manually_scheduled_review(&card, usn, 0)?; - } card.schedule_as_new(position); + if log { + col.log_manually_scheduled_review(&card, &original, usn)?; + } col.update_card(&mut card, &original, usn)?; position += 1; } diff --git a/rslib/src/sched/reviews.rs b/rslib/src/sched/reviews.rs index c2db2d5b2..48ed884c7 100644 --- a/rslib/src/sched/reviews.rs +++ b/rslib/src/sched/reviews.rs @@ -6,14 +6,52 @@ use crate::{ collection::Collection, deckconf::INITIAL_EASE_FACTOR_THOUSANDS, err::Result, + prelude::AnkiError, }; +use lazy_static::lazy_static; use rand::distributions::{Distribution, Uniform}; +use regex::Regex; impl Card { - fn schedule_as_review(&mut self, interval: u32, today: u32) { + /// Make card due in `days_from_today`. + /// If card is not a review card, convert it into one. + /// Relearning cards have their interval preserved. Normal review + /// cards have their interval adjusted based on change between the + /// previous and new due date. + fn set_due_date(&mut self, today: u32, days_from_today: u32) { + let new_due = (today + days_from_today) as i32; + let new_interval = if let Some(old_due) = self.current_review_due_day() { + // review cards have their interval shifted based on actual elapsed time + let days_early = old_due - new_due; + ((self.interval as i32) - days_early).max(0) as u32 + } else if self.ctype == CardType::Relearn { + // We can't know how early or late this card entered relearning + // without consulting the revlog, which may not exist. If the user + // has their deck set up to reduce but not zero the interval on + // failure, the card may potentially have an interval of weeks or + // months, so we'll favour that if it's larger than the chosen + // `days_from_today` + self.interval.max(days_from_today) + } else { + // other cards are given a new starting interval + days_from_today + }; + + self.schedule_as_review(new_interval, new_due); + } + + // For review cards not in relearning, return the day the card is due. + fn current_review_due_day(&self) -> Option { + match self.ctype { + CardType::New | CardType::Learn | CardType::Relearn => None, + CardType::Review => Some(self.original_or_current_due()), + } + } + + fn schedule_as_review(&mut self, interval: u32, due: i32) { self.remove_from_filtered_deck_before_reschedule(); self.interval = interval.max(1); - self.due = (today + interval) as i32; + self.due = due; self.ctype = CardType::Review; self.queue = CardQueue::Review; if self.ease_factor == 0 { @@ -24,13 +62,34 @@ impl Card { } } +/// Parse a number or range (eg '4' or '4..7') into min and max. +pub fn parse_due_date_str(s: &str) -> Result<(u32, u32)> { + lazy_static! { + static ref SINGLE: Regex = Regex::new(r#"^\d+$"#).unwrap(); + static ref RANGE: Regex = Regex::new( + r#"(?x)^ + (\d+) + \.\. + (\d+) + $ + "# + ) + .unwrap(); + } + if SINGLE.is_match(s) { + let num: u32 = s.parse()?; + Ok((num, num)) + } else if let Some(cap) = RANGE.captures_iter(s).next() { + let one: u32 = cap[1].parse()?; + let two: u32 = cap[2].parse()?; + Ok((one.min(two), two.max(one))) + } else { + Err(AnkiError::ParseNumError) + } +} + impl Collection { - pub fn reschedule_cards_as_reviews( - &mut self, - cids: &[CardID], - min_days: u32, - max_days: u32, - ) -> Result<()> { + pub fn set_due_date(&mut self, cids: &[CardID], min_days: u32, max_days: u32) -> Result<()> { let usn = self.usn()?; let today = self.timing_today()?.days_elapsed; let mut rng = rand::thread_rng(); @@ -39,9 +98,9 @@ impl Collection { col.storage.set_search_table_to_card_ids(cids, false)?; for mut card in col.storage.all_searched_cards()? { let original = card.clone(); - let interval = distribution.sample(&mut rng); - col.log_manually_scheduled_review(&card, usn, interval.max(1))?; - card.schedule_as_review(interval, today); + let days_from_today = distribution.sample(&mut rng); + card.set_due_date(today, days_from_today); + col.log_manually_scheduled_review(&card, &original, usn)?; col.update_card(&mut card, &original, usn)?; } col.storage.clear_searched_cards_table()?; @@ -49,3 +108,71 @@ impl Collection { }) } } + +#[cfg(test)] +mod test { + use super::*; + use crate::prelude::*; + + #[test] + fn parse() -> Result<()> { + assert!(parse_due_date_str("").is_err()); + assert!(parse_due_date_str("x").is_err()); + assert!(parse_due_date_str("-5").is_err()); + assert_eq!(parse_due_date_str("5")?, (5, 5)); + assert_eq!(parse_due_date_str("50..70")?, (50, 70)); + assert_eq!(parse_due_date_str("70..50")?, (50, 70)); + Ok(()) + } + + #[test] + fn due_date() { + let mut c = Card::new(NoteID(0), 0, DeckID(0), 0); + + // setting the due date of a new card will convert it + c.set_due_date(5, 2); + assert_eq!(c.ctype, CardType::Review); + assert_eq!(c.due, 7); + assert_eq!(c.interval, 2); + + // reschedule it again the next day, shifting it from day 7 to day 9 + c.set_due_date(6, 3); + assert_eq!(c.due, 9); + // we moved it 2 days forward from its original 2 day interval, and the + // interval should match the new delay + assert_eq!(c.interval, 4); + + // we can bring cards forward too - return it to its original due date + c.set_due_date(6, 1); + assert_eq!(c.due, 7); + assert_eq!(c.interval, 2); + + // should work in a filtered deck + c.original_due = 7; + c.original_deck_id = DeckID(1); + c.due = -10000; + c.queue = CardQueue::New; + c.set_due_date(6, 1); + assert_eq!(c.due, 7); + assert_eq!(c.interval, 2); + assert_eq!(c.queue, CardQueue::Review); + assert_eq!(c.original_due, 0); + assert_eq!(c.original_deck_id, DeckID(0)); + + // when relearning, a larger delay than the interval will win + c.ctype = CardType::Relearn; + c.original_due = c.due; + c.due = 12345678; + c.set_due_date(6, 10); + assert_eq!(c.due, 16); + assert_eq!(c.interval, 10); + + // but a shorter delay will preserve the current interval + c.ctype = CardType::Relearn; + c.original_due = c.due; + c.due = 12345678; + c.set_due_date(6, 1); + assert_eq!(c.due, 7); + assert_eq!(c.interval, 10); + } +}