diff --git a/ftl/core/preferences.ftl b/ftl/core/preferences.ftl
index 3266d489b..3e6f436b6 100644
--- a/ftl/core/preferences.ftl
+++ b/ftl/core/preferences.ftl
@@ -1,4 +1,3 @@
-preferences-anki-21-scheduler-beta = Anki 2.1 scheduler (beta)
preferences-automatically-sync-on-profile-openclose = Automatically sync on profile open/close
preferences-backups = Backups
preferences-backups2 = backups
@@ -36,3 +35,4 @@ preferences-timebox-time-limit = Timebox time limit
preferences-user-interface-size = User interface size
preferences-when-adding-default-to-current-deck = When adding, default to current deck
preferences-you-can-restore-backups-via-fileswitch = You can restore backups via File>Switch Profile.
+preferences-legacy-timezone-handling = Legacy timezone handling (buggy, but required for AnkiDroid <= 2.14)
diff --git a/ftl/core/scheduling.ftl b/ftl/core/scheduling.ftl
index 1fdac96b7..e74c98826 100644
--- a/ftl/core/scheduling.ftl
+++ b/ftl/core/scheduling.ftl
@@ -91,6 +91,14 @@ scheduling-how-to-custom-study = If you wish to study outside of the regular sch
# "... you can use the custom study feature."
scheduling-custom-study = custom study
+## Scheduler upgrade
+
+scheduling-update-soon = You are currently using Anki's old scheduler, which will be retired soon. Please make sure all of your devices are in sync, and then update to the new scheduler.
+scheduling-update-done = Scheduler updated successfully.
+scheduling-update-button = Update
+scheduling-update-later-button = Later
+scheduling-update-more-info-button = Learn More
+
## Other scheduling strings
scheduling-always-include-question-side-when-replaying = Always include question side when replaying audio
diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py
index 2ca41415d..315bbdf2f 100644
--- a/pylib/anki/collection.py
+++ b/pylib/anki/collection.py
@@ -140,25 +140,9 @@ class Collection:
elif ver == 2:
self.sched = V2Scheduler(self)
- def changeSchedulerVer(self, ver: int) -> None:
- if ver == self.schedVer():
- return
- if ver not in self.supportedSchedulerVersions:
- raise Exception("Unsupported scheduler version")
-
- self.modSchema(check=True)
+ def upgrade_to_v2_scheduler(self) -> None:
+ self._backend.upgrade_scheduler()
self.clearUndo()
-
- v2Sched = V2Scheduler(self)
-
- if ver == 1:
- v2Sched.moveToV1()
- else:
- v2Sched.moveToV2()
-
- self.conf["schedVer"] = ver
- self.setMod()
-
self._loadScheduler()
# DB-related
diff --git a/pylib/anki/importing/anki2.py b/pylib/anki/importing/anki2.py
index df4e9bb39..b29137396 100644
--- a/pylib/anki/importing/anki2.py
+++ b/pylib/anki/importing/anki2.py
@@ -30,7 +30,7 @@ class Anki2Importer(Importer):
# set later, defined here for typechecking
self._decks: Dict[int, int] = {}
- self.mustResetLearning = False
+ self.source_needs_upgrade = False
def run(self, media: None = None) -> None:
self._prepareFiles()
@@ -44,7 +44,7 @@ class Anki2Importer(Importer):
def _prepareFiles(self) -> None:
importingV2 = self.file.endswith(".anki21")
- self.mustResetLearning = False
+ self.source_needs_upgrade = False
self.dst = self.col
self.src = Collection(self.file)
@@ -52,7 +52,9 @@ class Anki2Importer(Importer):
if not importingV2 and self.col.schedVer() != 1:
# any scheduling included?
if self.src.db.scalar("select 1 from cards where queue != 0 limit 1"):
- self.mustResetLearning = True
+ self.source_needs_upgrade = True
+ elif importingV2 and self.col.schedVer() == 1:
+ raise Exception("must upgrade to new scheduler to import this file")
def _import(self) -> None:
self._decks = {}
@@ -300,9 +302,8 @@ class Anki2Importer(Importer):
######################################################################
def _importCards(self) -> None:
- if self.mustResetLearning:
- self.src.modSchema(check=False)
- self.src.changeSchedulerVer(2)
+ if self.source_needs_upgrade:
+ self.src.upgrade_to_v2_scheduler()
# build map of (guid, ord) -> cid and used id cache
self._cards: Dict[Tuple[str, int], int] = {}
existing = {}
diff --git a/pylib/anki/schedv2.py b/pylib/anki/schedv2.py
index 8aa18459c..8871edcf0 100644
--- a/pylib/anki/schedv2.py
+++ b/pylib/anki/schedv2.py
@@ -1474,89 +1474,3 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""",
# in order due?
if conf["new"]["order"] == NEW_CARDS_RANDOM:
self.randomizeCards(did)
-
- # Changing scheduler versions
- ##########################################################################
-
- def _emptyAllFiltered(self) -> None:
- self.col.db.execute(
- f"""
-update cards set did = odid, queue = (case
-when type = {CARD_TYPE_LRN} then {QUEUE_TYPE_NEW}
-when type = {CARD_TYPE_RELEARNING} then {QUEUE_TYPE_REV}
-else type end), type = (case
-when type = {CARD_TYPE_LRN} then {CARD_TYPE_NEW}
-when type = {CARD_TYPE_RELEARNING} then {CARD_TYPE_REV}
-else type end),
-due = odue, odue = 0, odid = 0, usn = ? where odid != 0""",
- self.col.usn(),
- )
-
- def _removeAllFromLearning(self, schedVer: int = 2) -> None:
- # remove review cards from relearning
- if schedVer == 1:
- self.col.db.execute(
- f"""
- update cards set
- due = odue, queue = {QUEUE_TYPE_REV}, type = {CARD_TYPE_REV}, mod = %d, usn = %d, odue = 0
- where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type in ({CARD_TYPE_REV}, {CARD_TYPE_RELEARNING})
- """
- % (intTime(), self.col.usn())
- )
- else:
- self.col.db.execute(
- f"""
- update cards set
- due = %d+ivl, queue = {QUEUE_TYPE_REV}, type = {CARD_TYPE_REV}, mod = %d, usn = %d, odue = 0
- where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN}) and type in ({CARD_TYPE_REV}, {CARD_TYPE_RELEARNING})
- """
- % (self.today, intTime(), self.col.usn())
- )
- # remove new cards from learning
- self.forgetCards(
- self.col.db.list(
- f"select id from cards where queue in ({QUEUE_TYPE_LRN},{QUEUE_TYPE_DAY_LEARN_RELEARN})"
- )
- )
-
- # v1 doesn't support buried/suspended (re)learning cards
- def _resetSuspendedLearning(self) -> None:
- self.col.db.execute(
- f"""
-update cards set type = (case
-when type = {CARD_TYPE_LRN} then {CARD_TYPE_NEW}
-when type in ({CARD_TYPE_REV}, {CARD_TYPE_RELEARNING}) then {CARD_TYPE_REV}
-else type end),
-due = (case when odue then odue else due end),
-odue = 0,
-mod = %d, usn = %d
-where queue < {QUEUE_TYPE_NEW}"""
- % (intTime(), self.col.usn())
- )
-
- # no 'manually buried' queue in v1
- def _moveManuallyBuried(self) -> None:
- self.col.db.execute(
- f"update cards set queue={QUEUE_TYPE_SIBLING_BURIED},mod=%d where queue={QUEUE_TYPE_MANUALLY_BURIED}"
- % intTime()
- )
-
- # adding 'hard' in v2 scheduler means old ease entries need shifting
- # up or down
- def _remapLearningAnswers(self, sql: str) -> None:
- self.col.db.execute(
- f"update revlog set %s and type in ({CARD_TYPE_NEW},{CARD_TYPE_REV})" % sql
- )
-
- def moveToV1(self) -> None:
- self._emptyAllFiltered()
- self._removeAllFromLearning()
-
- self._moveManuallyBuried()
- self._resetSuspendedLearning()
- self._remapLearningAnswers("ease=ease-1 where ease in (3,4)")
-
- def moveToV2(self) -> None:
- self._emptyAllFiltered()
- self._removeAllFromLearning(schedVer=1)
- self._remapLearningAnswers("ease=ease+1 where ease in (2,3)")
diff --git a/pylib/tests/test_exporting.py b/pylib/tests/test_exporting.py
index b112c8e70..f7dae2118 100644
--- a/pylib/tests/test_exporting.py
+++ b/pylib/tests/test_exporting.py
@@ -12,7 +12,7 @@ from tests.shared import getEmptyCol as getEmptyColOrig
def getEmptyCol():
col = getEmptyColOrig()
- col.changeSchedulerVer(2)
+ col.upgrade_to_v2_scheduler()
return col
diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py
index a953fafd9..b88d3cf97 100644
--- a/pylib/tests/test_schedv1.py
+++ b/pylib/tests/test_schedv1.py
@@ -11,7 +11,8 @@ from tests.shared import getEmptyCol as getEmptyColOrig
def getEmptyCol():
col = getEmptyColOrig()
- col.changeSchedulerVer(1)
+ # only safe in test environment
+ col.set_config("schedVer", 1)
return col
diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py
index 47ab860ff..48dd52cc4 100644
--- a/pylib/tests/test_schedv2.py
+++ b/pylib/tests/test_schedv2.py
@@ -13,7 +13,7 @@ from tests.shared import getEmptyCol as getEmptyColOrig
def getEmptyCol():
col = getEmptyColOrig()
- col.changeSchedulerVer(2)
+ col.upgrade_to_v2_scheduler()
return col
@@ -1180,64 +1180,6 @@ def test_failmult():
assert c.ivl == 25
-def test_moveVersions():
- col = getEmptyCol()
- col.changeSchedulerVer(1)
-
- n = col.newNote()
- n["Front"] = "one"
- col.addNote(n)
-
- # make it a learning card
- col.reset()
- c = col.sched.getCard()
- col.sched.answerCard(c, 1)
-
- # the move to v2 should reset it to new
- col.changeSchedulerVer(2)
- c.load()
- assert c.queue == QUEUE_TYPE_NEW
- assert c.type == CARD_TYPE_NEW
-
- # fail it again, and manually bury it
- col.reset()
- c = col.sched.getCard()
- col.sched.answerCard(c, 1)
- col.sched.bury_cards([c.id])
- c.load()
- assert c.queue == QUEUE_TYPE_MANUALLY_BURIED
-
- # revert to version 1
- col.changeSchedulerVer(1)
-
- # card should have moved queues
- c.load()
- assert c.queue == QUEUE_TYPE_SIBLING_BURIED
-
- # and it should be new again when unburied
- col.sched.unbury_cards_in_current_deck()
- c.load()
- assert c.type == CARD_TYPE_NEW and c.queue == QUEUE_TYPE_NEW
-
- # make sure relearning cards transition correctly to v1
- col.changeSchedulerVer(2)
- # card with 100 day interval, answering again
- col.sched.reschedCards([c.id], 100, 100)
- c.load()
- c.due = 0
- c.flush()
- conf = col.sched._cardConf(c)
- conf["lapse"]["mult"] = 0.5
- col.decks.save(conf)
- col.sched.reset()
- c = col.sched.getCard()
- col.sched.answerCard(c, 1)
- # due should be correctly set when removed from learning early
- col.changeSchedulerVer(1)
- c.load()
- assert c.due == 50
-
-
# cards with a due date earlier than the collection should retain
# their due date when removed
def test_negativeDueFilter():
diff --git a/pylib/tests/test_undo.py b/pylib/tests/test_undo.py
index 70b08ce0a..b1d6a8b79 100644
--- a/pylib/tests/test_undo.py
+++ b/pylib/tests/test_undo.py
@@ -8,7 +8,7 @@ from tests.shared import getEmptyCol as getEmptyColOrig
def getEmptyCol():
col = getEmptyColOrig()
- col.changeSchedulerVer(2)
+ col.upgrade_to_v2_scheduler()
return col
diff --git a/qt/aqt/data/web/css/deckbrowser.scss b/qt/aqt/data/web/css/deckbrowser.scss
index a192fbc74..e425978db 100644
--- a/qt/aqt/data/web/css/deckbrowser.scss
+++ b/qt/aqt/data/web/css/deckbrowser.scss
@@ -75,3 +75,13 @@ body {
filter: invert(180);
}
}
+
+.callout {
+ background: var(--medium-border);
+ padding: 1em;
+ margin: 1em;
+
+ div {
+ margin: 1em;
+ }
+}
\ No newline at end of file
diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py
index de69648b8..c5f4ef5d0 100644
--- a/qt/aqt/deckbrowser.py
+++ b/qt/aqt/deckbrowser.py
@@ -10,11 +10,21 @@ from typing import Any
import aqt
from anki.decks import DeckTreeNode
from anki.errors import DeckRenameError
+from anki.utils import intTime
from aqt import AnkiQt, gui_hooks
from aqt.qt import *
from aqt.sound import av_player
from aqt.toolbar import BottomBar
-from aqt.utils import TR, askUser, getOnlyText, openLink, shortcut, showWarning, tr
+from aqt.utils import (
+ TR,
+ askUser,
+ getOnlyText,
+ openLink,
+ shortcut,
+ showInfo,
+ showWarning,
+ tr,
+)
class DeckBrowserBottomBar:
@@ -49,6 +59,7 @@ class DeckBrowser:
self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0)
+ self._v1_message_dismissed_at = 0
def show(self) -> None:
av_player.stop_and_clear_queue()
@@ -87,6 +98,13 @@ class DeckBrowser:
self._handle_drag_and_drop(int(source), int(target or 0))
elif cmd == "collapse":
self._collapse(int(arg))
+ elif cmd == "v2upgrade":
+ self._confirm_upgrade()
+ elif cmd == "v2upgradeinfo":
+ openLink("https://faqs.ankiweb.net/the-anki-2.1-scheduler.html")
+ elif cmd == "v2upgradelater":
+ self._v1_message_dismissed_at = intTime()
+ self.refresh()
return False
def _selDeck(self, did: str) -> None:
@@ -121,7 +139,7 @@ class DeckBrowser:
)
gui_hooks.deck_browser_will_render_content(self, content)
self.web.stdHtml(
- self._body % content.__dict__,
+ self._v1_upgrade_message() + self._body % content.__dict__,
css=["css/deckbrowser.css"],
js=[
"js/vendor/jquery.min.js",
@@ -333,3 +351,45 @@ class DeckBrowser:
def _onShared(self) -> None:
openLink(f"{aqt.appShared}decks/")
+
+ ######################################################################
+
+ def _v1_upgrade_message(self) -> str:
+ if self.mw.col.schedVer() == 2:
+ return ""
+ if (intTime() - self._v1_message_dismissed_at) < 86_400:
+ return ""
+
+ return f"""
+
+
+
+ {tr(TR.SCHEDULING_UPDATE_SOON)}
+
+
+
+
+
+
+
+
+"""
+
+ def _confirm_upgrade(self) -> None:
+ self.mw.col.modSchema(check=True)
+ self.mw.col.upgrade_to_v2_scheduler()
+
+ # not translated, as 2.15 should not be too far off
+ if askUser("Do you sync with AnkiDroid 2.14 or earlier?", defaultno=True):
+ prefs = self.mw.col.get_preferences()
+ prefs.sched.new_timezone = False
+ self.mw.col.set_preferences(prefs)
+
+ showInfo(tr(TR.SCHEDULING_UPDATE_DONE))
+ self.refresh()
diff --git a/qt/aqt/forms/preferences.ui b/qt/aqt/forms/preferences.ui
index 7c4253f35..3e5293461 100644
--- a/qt/aqt/forms/preferences.ui
+++ b/qt/aqt/forms/preferences.ui
@@ -7,7 +7,7 @@
0
0
640
- 406
+ 419
@@ -199,16 +199,9 @@
-
-
+
- PREFERENCES_ANKI_21_SCHEDULER_BETA
-
-
-
- -
-
-
- New timezone handling (not yet supported by AnkiDroid)
+ PREFERENCES_LEGACY_TIMEZONE_HANDLING
@@ -604,8 +597,7 @@
showEstimates
showProgress
dayLearnFirst
- newSched
- new_timezone
+ legacy_timezone
newSpread
dayOffset
lrnCutoff
diff --git a/qt/aqt/preferences.py b/qt/aqt/preferences.py
index 062e92e62..00dd9eaf8 100644
--- a/qt/aqt/preferences.py
+++ b/qt/aqt/preferences.py
@@ -8,7 +8,6 @@ from aqt.qt import *
from aqt.utils import (
TR,
HelpPage,
- askUser,
disable_help_button,
openHelp,
showInfo,
@@ -124,10 +123,9 @@ class Preferences(QDialog):
if s.scheduler_version < 2:
f.dayLearnFirst.setVisible(False)
- f.new_timezone.setVisible(False)
+ f.legacy_timezone.setVisible(False)
else:
- f.newSched.setChecked(True)
- f.new_timezone.setChecked(s.new_timezone)
+ f.legacy_timezone.setChecked(not s.new_timezone)
def setup_video_driver(self) -> None:
self.video_drivers = VideoDriver.all_for_platform()
@@ -163,33 +161,12 @@ class Preferences(QDialog):
s.learn_ahead_secs = f.lrnCutoff.value() * 60
s.day_learn_first = f.dayLearnFirst.isChecked()
s.rollover = f.dayOffset.value()
- s.new_timezone = f.new_timezone.isChecked()
+ s.new_timezone = not f.legacy_timezone.isChecked()
- # if moving this, make sure scheduler change is moved to Rust or
- # happens afterwards
self.mw.col.set_preferences(self.prefs)
- self._updateSchedVer(f.newSched.isChecked())
d.setMod()
- # Scheduler version
- ######################################################################
-
- def _updateSchedVer(self, wantNew: bool) -> None:
- haveNew = self.mw.col.schedVer() == 2
-
- # nothing to do?
- if haveNew == wantNew:
- return
-
- if not askUser(tr(TR.PREFERENCES_THIS_WILL_RESET_ANY_CARDS_IN)):
- return
-
- if wantNew:
- self.mw.col.changeSchedulerVer(2)
- else:
- self.mw.col.changeSchedulerVer(1)
-
# Network
######################################################################
diff --git a/rslib/backend.proto b/rslib/backend.proto
index 3c590079c..65c04efd8 100644
--- a/rslib/backend.proto
+++ b/rslib/backend.proto
@@ -118,6 +118,7 @@ service BackendService {
rpc GetNextCardStates(CardID) returns (NextCardStates);
rpc DescribeNextStates(NextCardStates) returns (StringList);
rpc AnswerCard(AnswerCardIn) returns (Empty);
+ rpc UpgradeScheduler(Empty) returns (Empty);
// stats
@@ -997,7 +998,9 @@ message CollectionSchedulingSettings {
NEW_FIRST = 2;
}
+ // read only
uint32 scheduler_version = 1;
+
uint32 rollover = 2;
uint32 learn_ahead_secs = 3;
NewReviewMix new_review_mix = 4;
diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs
index b56302808..235018f47 100644
--- a/rslib/src/backend/mod.rs
+++ b/rslib/src/backend/mod.rs
@@ -711,6 +711,11 @@ impl BackendService for Backend {
.map(Into::into)
}
+ fn upgrade_scheduler(&self, _input: Empty) -> BackendResult {
+ self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler()))
+ .map(Into::into)
+ }
+
// statistics
//-----------------------------------------------
diff --git a/rslib/src/card.rs b/rslib/src/card.rs
index 1930eb793..93ac02ba5 100644
--- a/rslib/src/card.rs
+++ b/rslib/src/card.rs
@@ -216,7 +216,7 @@ impl Collection {
return Err(AnkiError::DeckIsFiltered);
}
self.storage.set_search_table_to_card_ids(cards, false)?;
- let sched = self.sched_ver();
+ let sched = self.scheduler_version();
let usn = self.usn()?;
self.transact(None, |col| {
for mut card in col.storage.all_searched_cards()? {
diff --git a/rslib/src/config.rs b/rslib/src/config.rs
index eb1555a42..925dde90c 100644
--- a/rslib/src/config.rs
+++ b/rslib/src/config.rs
@@ -254,11 +254,16 @@ impl Collection {
self.set_config(ConfigKey::NextNewCardPosition, &pos)
}
- pub(crate) fn sched_ver(&self) -> SchedulerVersion {
+ pub(crate) fn scheduler_version(&self) -> SchedulerVersion {
self.get_config_optional(ConfigKey::SchedulerVersion)
.unwrap_or(SchedulerVersion::V1)
}
+ /// Caution: this only updates the config setting.
+ pub(crate) fn set_scheduler_version_config_key(&self, ver: SchedulerVersion) -> Result<()> {
+ self.set_config(ConfigKey::SchedulerVersion, &ver)
+ }
+
pub(crate) fn learn_ahead_secs(&self) -> u32 {
self.get_config_optional(ConfigKey::LearnAheadSecs)
.unwrap_or(1200)
diff --git a/rslib/src/dbcheck.rs b/rslib/src/dbcheck.rs
index 9035de2f8..95b1bbe24 100644
--- a/rslib/src/dbcheck.rs
+++ b/rslib/src/dbcheck.rs
@@ -14,10 +14,7 @@ use crate::{
};
use itertools::Itertools;
use slog::debug;
-use std::{
- collections::{HashMap, HashSet},
- sync::Arc,
-};
+use std::{collections::HashSet, sync::Arc};
#[derive(Debug, Default, PartialEq)]
pub struct CheckDatabaseOutput {
@@ -200,12 +197,7 @@ impl Collection {
}
fn check_filtered_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {
- let decks: HashMap<_, _> = self
- .storage
- .get_all_decks()?
- .into_iter()
- .map(|d| (d.id, d))
- .collect();
+ let decks = self.storage.get_decks_map()?;
let mut wrong = 0;
for (cid, did) in self.storage.all_filtered_cards_by_deck()? {
diff --git a/rslib/src/decks/counts.rs b/rslib/src/decks/counts.rs
index 066ccdd7f..80c153daa 100644
--- a/rslib/src/decks/counts.rs
+++ b/rslib/src/decks/counts.rs
@@ -19,7 +19,11 @@ impl Collection {
learn_cutoff: u32,
limit_to: Option<&str>,
) -> Result> {
- self.storage
- .due_counts(self.sched_ver(), days_elapsed, learn_cutoff, limit_to)
+ self.storage.due_counts(
+ self.scheduler_version(),
+ days_elapsed,
+ learn_cutoff,
+ limit_to,
+ )
}
}
diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs
index b60d7b794..98cc94ca1 100644
--- a/rslib/src/decks/tree.rs
+++ b/rslib/src/decks/tree.rs
@@ -224,12 +224,7 @@ impl Collection {
let names = self.storage.get_all_deck_names()?;
let mut tree = deck_names_to_tree(names);
- let decks_map: HashMap<_, _> = self
- .storage
- .get_all_decks()?
- .into_iter()
- .map(|d| (d.id, d))
- .collect();
+ let decks_map = self.storage.get_decks_map()?;
add_collapsed_and_filtered(&mut tree, &decks_map, now.is_none());
if self.default_deck_is_empty()? {
@@ -247,12 +242,7 @@ impl Collection {
let days_elapsed = self.timing_for_timestamp(now)?.days_elapsed;
let learn_cutoff = (now.0 as u32) + self.learn_ahead_secs();
let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?;
- let dconf: HashMap<_, _> = self
- .storage
- .all_deck_config()?
- .into_iter()
- .map(|d| (d.id, d))
- .collect();
+ let dconf = self.storage.get_deck_config_map()?;
add_counts(&mut tree, &counts);
apply_limits(
&mut tree,
diff --git a/rslib/src/filtered.rs b/rslib/src/filtered.rs
index b05c69675..eecf7731d 100644
--- a/rslib/src/filtered.rs
+++ b/rslib/src/filtered.rs
@@ -185,7 +185,7 @@ impl Collection {
// Unlike the old Python code, this also marks the cards as modified.
fn return_cards_to_home_deck(&mut self, cids: &[CardID]) -> Result<()> {
- let sched = self.sched_ver();
+ let sched = self.scheduler_version();
let usn = self.usn()?;
for cid in cids {
if let Some(mut card) = self.storage.get_card(*cid)? {
@@ -208,7 +208,7 @@ impl Collection {
let ctx = DeckFilterContext {
target_deck: did,
config,
- scheduler: self.sched_ver(),
+ scheduler: self.scheduler_version(),
usn: self.usn()?,
today: self.timing_today()?.days_elapsed,
};
diff --git a/rslib/src/notetype/stock.rs b/rslib/src/notetype/stock.rs
index b110f59ef..b124a398b 100644
--- a/rslib/src/notetype/stock.rs
+++ b/rslib/src/notetype/stock.rs
@@ -110,8 +110,10 @@ pub(crate) fn basic_optional_reverse(i18n: &I18n) -> NoteType {
}
pub(crate) fn cloze(i18n: &I18n) -> NoteType {
- let mut nt = NoteType::default();
- nt.name = i18n.tr(TR::NotetypesClozeName).into();
+ let mut nt = NoteType {
+ name: i18n.tr(TR::NotetypesClozeName).into(),
+ ..Default::default()
+ };
let text = i18n.tr(TR::NotetypesTextField);
nt.add_field(text.as_ref());
let back_extra = i18n.tr(TR::NotetypesBackExtraField);
diff --git a/rslib/src/preferences.rs b/rslib/src/preferences.rs
index aea983441..4237fcd3d 100644
--- a/rslib/src/preferences.rs
+++ b/rslib/src/preferences.rs
@@ -18,7 +18,7 @@ impl Collection {
})
}
- pub fn set_preferences(&self, prefs: Preferences) -> Result<()> {
+ pub fn set_preferences(&mut self, prefs: Preferences) -> Result<()> {
if let Some(sched) = prefs.sched {
self.set_collection_scheduling_settings(sched)?;
}
@@ -28,7 +28,7 @@ impl Collection {
pub fn get_collection_scheduling_settings(&self) -> Result {
Ok(CollectionSchedulingSettings {
- scheduler_version: match self.sched_ver() {
+ scheduler_version: match self.scheduler_version() {
crate::config::SchedulerVersion::V1 => 1,
crate::config::SchedulerVersion::V2 => 2,
},
@@ -48,7 +48,7 @@ impl Collection {
}
pub(crate) fn set_collection_scheduling_settings(
- &self,
+ &mut self,
settings: CollectionSchedulingSettings,
) -> Result<()> {
let s = settings;
@@ -79,7 +79,6 @@ impl Collection {
self.set_creation_utc_offset(None)?;
}
- // fixme: currently scheduler change unhandled
Ok(())
}
}
diff --git a/rslib/src/prelude.rs b/rslib/src/prelude.rs
index e21b6a4d1..54a5ca204 100644
--- a/rslib/src/prelude.rs
+++ b/rslib/src/prelude.rs
@@ -4,8 +4,8 @@
pub use crate::{
card::{Card, CardID},
collection::Collection,
- deckconf::DeckConfID,
- decks::DeckID,
+ deckconf::{DeckConf, DeckConfID},
+ decks::{Deck, DeckID, DeckKind},
err::{AnkiError, Result},
i18n::{tr_args, tr_strs, TR},
notes::{Note, NoteID},
diff --git a/rslib/src/sched/bury_and_suspend.rs b/rslib/src/sched/bury_and_suspend.rs
index 9ebfb5952..fa114b9b5 100644
--- a/rslib/src/sched/bury_and_suspend.rs
+++ b/rslib/src/sched/bury_and_suspend.rs
@@ -103,7 +103,7 @@ impl Collection {
) -> Result<()> {
use pb::bury_or_suspend_cards_in::Mode;
let usn = self.usn()?;
- let sched = self.sched_ver();
+ let sched = self.scheduler_version();
for original in self.storage.all_searched_cards()? {
let mut card = original.clone();
diff --git a/rslib/src/sched/mod.rs b/rslib/src/sched/mod.rs
index d4ac18280..4a1bfc941 100644
--- a/rslib/src/sched/mod.rs
+++ b/rslib/src/sched/mod.rs
@@ -12,6 +12,7 @@ pub mod new;
mod reviews;
pub mod states;
pub mod timespan;
+mod upgrade;
use chrono::FixedOffset;
use cutoff::{
@@ -32,7 +33,7 @@ impl Collection {
pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result {
let current_utc_offset = self.local_utc_offset_for_user()?;
- let rollover_hour = match self.sched_ver() {
+ let rollover_hour = match self.scheduler_version() {
SchedulerVersion::V1 => None,
SchedulerVersion::V2 => {
let configured_rollover = self.get_v2_rollover();
@@ -89,7 +90,7 @@ impl Collection {
}
pub fn rollover_for_current_scheduler(&self) -> Result {
- match self.sched_ver() {
+ match self.scheduler_version() {
SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp(
self.storage.creation_stamp()?.0,
)),
@@ -98,7 +99,7 @@ impl Collection {
}
pub(crate) fn set_rollover_for_current_scheduler(&self, hour: u8) -> Result<()> {
- match self.sched_ver() {
+ match self.scheduler_version() {
SchedulerVersion::V1 => {
self.storage
.set_creation_stamp(TimestampSecs(v1_creation_date_adjusted_to_hour(
diff --git a/rslib/src/sched/upgrade.rs b/rslib/src/sched/upgrade.rs
new file mode 100644
index 000000000..c7a5db9c9
--- /dev/null
+++ b/rslib/src/sched/upgrade.rs
@@ -0,0 +1,186 @@
+// Copyright: Ankitects Pty Ltd and contributors
+// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
+
+use std::collections::HashMap;
+
+use crate::{
+ card::{CardQueue, CardType},
+ config::SchedulerVersion,
+ prelude::*,
+ search::SortMode,
+};
+
+use super::cutoff::local_minutes_west_for_stamp;
+
+struct V1FilteredDeckInfo {
+ /// True if the filtered deck had rescheduling enabled.
+ reschedule: bool,
+ /// If the filtered deck had custom steps enabled, `original_step_count`
+ /// contains the step count of the home deck, which will be used to ensure
+ /// the remaining steps of the card are not out of bounds.
+ original_step_count: Option,
+}
+
+impl Card {
+ /// Update relearning cards and cards in filtered decks.
+ /// `filtered_info` should be provided if card is in a filtered deck.
+ fn upgrade_to_v2(&mut self, filtered_info: Option) {
+ // relearning cards have their own type
+ if self.ctype == CardType::Review
+ && matches!(self.queue, CardQueue::Learn | CardQueue::DayLearn)
+ {
+ self.ctype = CardType::Relearn;
+ }
+
+ // filtered deck handling
+ if let Some(info) = filtered_info {
+ // cap remaining count to home deck
+ if let Some(step_count) = info.original_step_count {
+ self.remaining_steps = self.remaining_steps.min(step_count);
+ }
+
+ if !info.reschedule {
+ // preview cards start in the review queue in v2
+ if self.queue == CardQueue::New {
+ self.queue = CardQueue::Review;
+ }
+
+ // to ensure learning cards are reset to new on exit, we must
+ // make them new now
+ if self.ctype == CardType::Learn {
+ self.queue = CardQueue::PreviewRepeat;
+ self.ctype = CardType::New;
+ }
+ }
+ }
+ }
+}
+
+fn get_filter_info_for_card(
+ card: &Card,
+ decks: &HashMap,
+ configs: &HashMap,
+) -> Option {
+ if card.original_deck_id.0 == 0 {
+ None
+ } else {
+ let (had_custom_steps, reschedule) = if let Some(deck) = decks.get(&card.deck_id) {
+ if let DeckKind::Filtered(filtered) = &deck.kind {
+ (!filtered.delays.is_empty(), filtered.reschedule)
+ } else {
+ // not a filtered deck, give up
+ return None;
+ }
+ } else {
+ // missing filtered deck, give up
+ return None;
+ };
+
+ let original_step_count = if had_custom_steps {
+ let home_conf_id = decks
+ .get(&card.original_deck_id)
+ .and_then(|deck| deck.config_id())
+ .unwrap_or(DeckConfID(1));
+ Some(
+ configs
+ .get(&home_conf_id)
+ .map(|config| {
+ if card.ctype == CardType::Review {
+ config.inner.relearn_steps.len()
+ } else {
+ config.inner.learn_steps.len()
+ }
+ })
+ .unwrap_or(0) as u32,
+ )
+ } else {
+ None
+ };
+
+ Some(V1FilteredDeckInfo {
+ reschedule,
+ original_step_count,
+ })
+ }
+}
+
+impl Collection {
+ /// Expects an existing transaction. No-op if already on v2.
+ pub(crate) fn upgrade_to_v2_scheduler(&mut self) -> Result<()> {
+ if self.scheduler_version() == SchedulerVersion::V2 {
+ // nothing to do
+ return Ok(());
+ }
+
+ self.storage.upgrade_revlog_to_v2()?;
+ self.upgrade_cards_to_v2()?;
+ self.set_scheduler_version_config_key(SchedulerVersion::V2)?;
+
+ // enable new timezone code by default
+ let created = self.storage.creation_stamp()?;
+ if self.get_creation_utc_offset().is_none() {
+ self.set_creation_utc_offset(Some(local_minutes_west_for_stamp(created.0)))?;
+ }
+
+ // force full sync
+ self.storage.set_schema_modified()
+ }
+
+ fn upgrade_cards_to_v2(&mut self) -> Result<()> {
+ let count = self.search_cards_into_table(
+ // can't add 'is:learn' here, as it matches on card type, not card queue
+ "deck:filtered OR is:review",
+ SortMode::NoOrder,
+ )?;
+ if count > 0 {
+ let decks = self.storage.get_decks_map()?;
+ let configs = self.storage.get_deck_config_map()?;
+ self.storage.for_each_card_in_search(|mut card| {
+ let filtered_info = get_filter_info_for_card(&card, &decks, &configs);
+ card.upgrade_to_v2(filtered_info);
+ self.storage.update_card(&card)
+ })?;
+ }
+ self.storage.clear_searched_cards_table()
+ }
+}
+#[cfg(test)]
+mod test {
+ use super::*;
+
+ #[test]
+ fn v2_card() {
+ let mut c = Card::default();
+
+ // relearning cards should be reclassified
+ c.ctype = CardType::Review;
+ c.queue = CardQueue::DayLearn;
+ c.upgrade_to_v2(None);
+ assert_eq!(c.ctype, CardType::Relearn);
+
+ // check step capping
+ c.remaining_steps = 5005;
+ c.upgrade_to_v2(Some(V1FilteredDeckInfo {
+ reschedule: true,
+ original_step_count: Some(2),
+ }));
+ assert_eq!(c.remaining_steps, 2);
+
+ // with rescheduling off, relearning cards don't need changing
+ c.upgrade_to_v2(Some(V1FilteredDeckInfo {
+ reschedule: false,
+ original_step_count: None,
+ }));
+ assert_eq!(c.ctype, CardType::Relearn);
+ assert_eq!(c.queue, CardQueue::DayLearn);
+
+ // but learning cards are reset to new
+ c.ctype = CardType::Learn;
+ c.upgrade_to_v2(Some(V1FilteredDeckInfo {
+ reschedule: false,
+ original_step_count: None,
+ }));
+ assert_eq!(c.ctype, CardType::New);
+ assert_eq!(c.queue, CardQueue::PreviewRepeat);
+ }
+}
diff --git a/rslib/src/search/cards.rs b/rslib/src/search/cards.rs
index b7289abe9..4661f5f84 100644
--- a/rslib/src/search/cards.rs
+++ b/rslib/src/search/cards.rs
@@ -91,7 +91,12 @@ impl Collection {
/// Place the matched card ids into a temporary 'search_cids' table
/// instead of returning them. Use clear_searched_cards() to remove it.
- pub(crate) fn search_cards_into_table(&mut self, search: &str, mode: SortMode) -> Result<()> {
+ /// Returns number of added cards.
+ pub(crate) fn search_cards_into_table(
+ &mut self,
+ search: &str,
+ mode: SortMode,
+ ) -> Result {
let top_node = Node::Group(parse(search)?);
let writer = SqlWriter::new(self);
let want_order = mode != SortMode::NoOrder;
@@ -107,9 +112,11 @@ impl Collection {
}
let sql = format!("insert into search_cids {}", sql);
- self.storage.db.prepare(&sql)?.execute(&args)?;
-
- Ok(())
+ self.storage
+ .db
+ .prepare(&sql)?
+ .execute(&args)
+ .map_err(Into::into)
}
/// If the sort mode is based on a config setting, look it up.
diff --git a/rslib/src/stats/graphs.rs b/rslib/src/stats/graphs.rs
index 65e9387a0..5d52c159b 100644
--- a/rslib/src/stats/graphs.rs
+++ b/rslib/src/stats/graphs.rs
@@ -42,7 +42,7 @@ impl Collection {
revlog,
days_elapsed: timing.days_elapsed,
next_day_at_secs: timing.next_day_at as u32,
- scheduler_version: self.sched_ver() as u32,
+ scheduler_version: self.scheduler_version() as u32,
local_offset_secs: local_offset_secs as i32,
})
}
diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs
index 00ea9c9fe..b69224c0c 100644
--- a/rslib/src/storage/deck/mod.rs
+++ b/rslib/src/storage/deck/mod.rs
@@ -66,6 +66,14 @@ impl SqliteStorage {
.collect()
}
+ pub(crate) fn get_decks_map(&self) -> Result> {
+ self.db
+ .prepare(include_str!("get_deck.sql"))?
+ .query_and_then(NO_PARAMS, row_to_deck)?
+ .map(|res| res.map(|d| (d.id, d)))
+ .collect()
+ }
+
/// Get all deck names in sorted, human-readable form (::)
pub(crate) fn get_all_deck_names(&self) -> Result> {
self.db
diff --git a/rslib/src/storage/deckconf/mod.rs b/rslib/src/storage/deckconf/mod.rs
index 3bafdc9bf..b57917dbf 100644
--- a/rslib/src/storage/deckconf/mod.rs
+++ b/rslib/src/storage/deckconf/mod.rs
@@ -30,6 +30,14 @@ impl SqliteStorage {
.collect()
}
+ pub(crate) fn get_deck_config_map(&self) -> Result> {
+ self.db
+ .prepare_cached(include_str!("get.sql"))?
+ .query_and_then(NO_PARAMS, row_to_deckconf)?
+ .map(|res| res.map(|d| (d.id, d)))
+ .collect()
+ }
+
pub(crate) fn get_deck_config(&self, dcid: DeckConfID) -> Result