rework v2 scheduler upgrade; drop downgrade
- Rework V2 upgrade so that it no longer resets cards in learning, or empties filtered decks. - V1 users will receive a message at the top of the deck list encouraging them to upgrade, and they can upgrade directly from that screen. - The setting in the preferences screen has been removed, so users will need to use an older Anki version if they wish to switch back to V1. - Prevent V2 exports with scheduling from being importable into a V1 collection - the code was previously allowing this when it shouldn't have been. - New collections still default to v1 at the moment. Also add helper to get map of decks and deck configs, as there were a few places in the codebase where that was required.
This commit is contained in:
parent
83c8d53da2
commit
5ae66af5d2
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 = {}
|
||||
|
@ -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)")
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -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():
|
||||
|
@ -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
|
||||
|
||||
|
||||
|
@ -75,3 +75,13 @@ body {
|
||||
filter: invert(180);
|
||||
}
|
||||
}
|
||||
|
||||
.callout {
|
||||
background: var(--medium-border);
|
||||
padding: 1em;
|
||||
margin: 1em;
|
||||
|
||||
div {
|
||||
margin: 1em;
|
||||
}
|
||||
}
|
@ -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"""
|
||||
<center>
|
||||
<div class=callout>
|
||||
<div>
|
||||
{tr(TR.SCHEDULING_UPDATE_SOON)}
|
||||
</div>
|
||||
<div>
|
||||
<button onclick='pycmd("v2upgrade")'>
|
||||
{tr(TR.SCHEDULING_UPDATE_BUTTON)}
|
||||
</button>
|
||||
<button onclick='pycmd("v2upgradeinfo")'>
|
||||
{tr(TR.SCHEDULING_UPDATE_MORE_INFO_BUTTON)}
|
||||
</button>
|
||||
<button onclick='pycmd("v2upgradelater")'>
|
||||
{tr(TR.SCHEDULING_UPDATE_LATER_BUTTON)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</center>
|
||||
"""
|
||||
|
||||
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()
|
||||
|
@ -7,7 +7,7 @@
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>640</width>
|
||||
<height>406</height>
|
||||
<height>419</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
@ -199,16 +199,9 @@
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="newSched">
|
||||
<widget class="QCheckBox" name="legacy_timezone">
|
||||
<property name="text">
|
||||
<string>PREFERENCES_ANKI_21_SCHEDULER_BETA</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QCheckBox" name="new_timezone">
|
||||
<property name="text">
|
||||
<string notr="true">New timezone handling (not yet supported by AnkiDroid)</string>
|
||||
<string>PREFERENCES_LEGACY_TIMEZONE_HANDLING</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
@ -604,8 +597,7 @@
|
||||
<tabstop>showEstimates</tabstop>
|
||||
<tabstop>showProgress</tabstop>
|
||||
<tabstop>dayLearnFirst</tabstop>
|
||||
<tabstop>newSched</tabstop>
|
||||
<tabstop>new_timezone</tabstop>
|
||||
<tabstop>legacy_timezone</tabstop>
|
||||
<tabstop>newSpread</tabstop>
|
||||
<tabstop>dayOffset</tabstop>
|
||||
<tabstop>lrnCutoff</tabstop>
|
||||
|
@ -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
|
||||
######################################################################
|
||||
|
||||
|
@ -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;
|
||||
|
@ -711,6 +711,11 @@ impl BackendService for Backend {
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
fn upgrade_scheduler(&self, _input: Empty) -> BackendResult<Empty> {
|
||||
self.with_col(|col| col.transact(None, |col| col.upgrade_to_v2_scheduler()))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
// statistics
|
||||
//-----------------------------------------------
|
||||
|
||||
|
@ -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()? {
|
||||
|
@ -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)
|
||||
|
@ -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()? {
|
||||
|
@ -19,7 +19,11 @@ impl Collection {
|
||||
learn_cutoff: u32,
|
||||
limit_to: Option<&str>,
|
||||
) -> Result<HashMap<DeckID, DueCounts>> {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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);
|
||||
|
@ -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<CollectionSchedulingSettings> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
@ -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},
|
||||
|
@ -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();
|
||||
|
@ -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<SchedTimingToday> {
|
||||
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<u8> {
|
||||
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(
|
||||
|
186
rslib/src/sched/upgrade.rs
Normal file
186
rslib/src/sched/upgrade.rs
Normal file
@ -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<u32>,
|
||||
}
|
||||
|
||||
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<V1FilteredDeckInfo>) {
|
||||
// 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<DeckID, Deck>,
|
||||
configs: &HashMap<DeckConfID, DeckConf>,
|
||||
) -> Option<V1FilteredDeckInfo> {
|
||||
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);
|
||||
}
|
||||
}
|
@ -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<usize> {
|
||||
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.
|
||||
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
@ -66,6 +66,14 @@ impl SqliteStorage {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_decks_map(&self) -> Result<HashMap<DeckID, Deck>> {
|
||||
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<Vec<(DeckID, String)>> {
|
||||
self.db
|
||||
|
@ -30,6 +30,14 @@ impl SqliteStorage {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn get_deck_config_map(&self) -> Result<HashMap<DeckConfID, DeckConf>> {
|
||||
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<Option<DeckConf>> {
|
||||
self.db
|
||||
.prepare_cached(concat!(include_str!("get.sql"), " where id = ?"))?
|
||||
|
@ -133,4 +133,10 @@ impl SqliteStorage {
|
||||
.unwrap()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub(crate) fn upgrade_revlog_to_v2(&self) -> Result<()> {
|
||||
self.db
|
||||
.execute_batch(include_str!("v2_upgrade.sql"))
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
4
rslib/src/storage/revlog/v2_upgrade.sql
Normal file
4
rslib/src/storage/revlog/v2_upgrade.sql
Normal file
@ -0,0 +1,4 @@
|
||||
UPDATE revlog
|
||||
SET ease = ease + 1
|
||||
WHERE ease IN (2, 3)
|
||||
AND type IN (0, 2);
|
Loading…
Reference in New Issue
Block a user