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);
+ }
+}