mirror of
https://github.com/ankitects/anki.git
synced 2025-09-19 06:22:22 -04:00
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
33 changed files with 367 additions and 258 deletions
|
@ -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-automatically-sync-on-profile-openclose = Automatically sync on profile open/close
|
||||||
preferences-backups = Backups
|
preferences-backups = Backups
|
||||||
preferences-backups2 = backups
|
preferences-backups2 = backups
|
||||||
|
@ -36,3 +35,4 @@ preferences-timebox-time-limit = Timebox time limit
|
||||||
preferences-user-interface-size = User interface size
|
preferences-user-interface-size = User interface size
|
||||||
preferences-when-adding-default-to-current-deck = When adding, default to current deck
|
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-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."
|
# "... you can use the custom study feature."
|
||||||
scheduling-custom-study = custom study
|
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
|
## Other scheduling strings
|
||||||
|
|
||||||
scheduling-always-include-question-side-when-replaying = Always include question side when replaying audio
|
scheduling-always-include-question-side-when-replaying = Always include question side when replaying audio
|
||||||
|
|
|
@ -140,25 +140,9 @@ class Collection:
|
||||||
elif ver == 2:
|
elif ver == 2:
|
||||||
self.sched = V2Scheduler(self)
|
self.sched = V2Scheduler(self)
|
||||||
|
|
||||||
def changeSchedulerVer(self, ver: int) -> None:
|
def upgrade_to_v2_scheduler(self) -> None:
|
||||||
if ver == self.schedVer():
|
self._backend.upgrade_scheduler()
|
||||||
return
|
|
||||||
if ver not in self.supportedSchedulerVersions:
|
|
||||||
raise Exception("Unsupported scheduler version")
|
|
||||||
|
|
||||||
self.modSchema(check=True)
|
|
||||||
self.clearUndo()
|
self.clearUndo()
|
||||||
|
|
||||||
v2Sched = V2Scheduler(self)
|
|
||||||
|
|
||||||
if ver == 1:
|
|
||||||
v2Sched.moveToV1()
|
|
||||||
else:
|
|
||||||
v2Sched.moveToV2()
|
|
||||||
|
|
||||||
self.conf["schedVer"] = ver
|
|
||||||
self.setMod()
|
|
||||||
|
|
||||||
self._loadScheduler()
|
self._loadScheduler()
|
||||||
|
|
||||||
# DB-related
|
# DB-related
|
||||||
|
|
|
@ -30,7 +30,7 @@ class Anki2Importer(Importer):
|
||||||
|
|
||||||
# set later, defined here for typechecking
|
# set later, defined here for typechecking
|
||||||
self._decks: Dict[int, int] = {}
|
self._decks: Dict[int, int] = {}
|
||||||
self.mustResetLearning = False
|
self.source_needs_upgrade = False
|
||||||
|
|
||||||
def run(self, media: None = None) -> None:
|
def run(self, media: None = None) -> None:
|
||||||
self._prepareFiles()
|
self._prepareFiles()
|
||||||
|
@ -44,7 +44,7 @@ class Anki2Importer(Importer):
|
||||||
|
|
||||||
def _prepareFiles(self) -> None:
|
def _prepareFiles(self) -> None:
|
||||||
importingV2 = self.file.endswith(".anki21")
|
importingV2 = self.file.endswith(".anki21")
|
||||||
self.mustResetLearning = False
|
self.source_needs_upgrade = False
|
||||||
|
|
||||||
self.dst = self.col
|
self.dst = self.col
|
||||||
self.src = Collection(self.file)
|
self.src = Collection(self.file)
|
||||||
|
@ -52,7 +52,9 @@ class Anki2Importer(Importer):
|
||||||
if not importingV2 and self.col.schedVer() != 1:
|
if not importingV2 and self.col.schedVer() != 1:
|
||||||
# any scheduling included?
|
# any scheduling included?
|
||||||
if self.src.db.scalar("select 1 from cards where queue != 0 limit 1"):
|
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:
|
def _import(self) -> None:
|
||||||
self._decks = {}
|
self._decks = {}
|
||||||
|
@ -300,9 +302,8 @@ class Anki2Importer(Importer):
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
def _importCards(self) -> None:
|
def _importCards(self) -> None:
|
||||||
if self.mustResetLearning:
|
if self.source_needs_upgrade:
|
||||||
self.src.modSchema(check=False)
|
self.src.upgrade_to_v2_scheduler()
|
||||||
self.src.changeSchedulerVer(2)
|
|
||||||
# build map of (guid, ord) -> cid and used id cache
|
# build map of (guid, ord) -> cid and used id cache
|
||||||
self._cards: Dict[Tuple[str, int], int] = {}
|
self._cards: Dict[Tuple[str, int], int] = {}
|
||||||
existing = {}
|
existing = {}
|
||||||
|
|
|
@ -1474,89 +1474,3 @@ and (queue={QUEUE_TYPE_NEW} or (queue={QUEUE_TYPE_REV} and due<=?))""",
|
||||||
# in order due?
|
# in order due?
|
||||||
if conf["new"]["order"] == NEW_CARDS_RANDOM:
|
if conf["new"]["order"] == NEW_CARDS_RANDOM:
|
||||||
self.randomizeCards(did)
|
self.randomizeCards(did)
|
||||||
|
|
||||||
# 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():
|
def getEmptyCol():
|
||||||
col = getEmptyColOrig()
|
col = getEmptyColOrig()
|
||||||
col.changeSchedulerVer(2)
|
col.upgrade_to_v2_scheduler()
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,8 @@ from tests.shared import getEmptyCol as getEmptyColOrig
|
||||||
|
|
||||||
def getEmptyCol():
|
def getEmptyCol():
|
||||||
col = getEmptyColOrig()
|
col = getEmptyColOrig()
|
||||||
col.changeSchedulerVer(1)
|
# only safe in test environment
|
||||||
|
col.set_config("schedVer", 1)
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,7 @@ from tests.shared import getEmptyCol as getEmptyColOrig
|
||||||
|
|
||||||
def getEmptyCol():
|
def getEmptyCol():
|
||||||
col = getEmptyColOrig()
|
col = getEmptyColOrig()
|
||||||
col.changeSchedulerVer(2)
|
col.upgrade_to_v2_scheduler()
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
@ -1180,64 +1180,6 @@ def test_failmult():
|
||||||
assert c.ivl == 25
|
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
|
# cards with a due date earlier than the collection should retain
|
||||||
# their due date when removed
|
# their due date when removed
|
||||||
def test_negativeDueFilter():
|
def test_negativeDueFilter():
|
||||||
|
|
|
@ -8,7 +8,7 @@ from tests.shared import getEmptyCol as getEmptyColOrig
|
||||||
|
|
||||||
def getEmptyCol():
|
def getEmptyCol():
|
||||||
col = getEmptyColOrig()
|
col = getEmptyColOrig()
|
||||||
col.changeSchedulerVer(2)
|
col.upgrade_to_v2_scheduler()
|
||||||
return col
|
return col
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -75,3 +75,13 @@ body {
|
||||||
filter: invert(180);
|
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
|
import aqt
|
||||||
from anki.decks import DeckTreeNode
|
from anki.decks import DeckTreeNode
|
||||||
from anki.errors import DeckRenameError
|
from anki.errors import DeckRenameError
|
||||||
|
from anki.utils import intTime
|
||||||
from aqt import AnkiQt, gui_hooks
|
from aqt import AnkiQt, gui_hooks
|
||||||
from aqt.qt import *
|
from aqt.qt import *
|
||||||
from aqt.sound import av_player
|
from aqt.sound import av_player
|
||||||
from aqt.toolbar import BottomBar
|
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:
|
class DeckBrowserBottomBar:
|
||||||
|
@ -49,6 +59,7 @@ class DeckBrowser:
|
||||||
self.web = mw.web
|
self.web = mw.web
|
||||||
self.bottom = BottomBar(mw, mw.bottomWeb)
|
self.bottom = BottomBar(mw, mw.bottomWeb)
|
||||||
self.scrollPos = QPoint(0, 0)
|
self.scrollPos = QPoint(0, 0)
|
||||||
|
self._v1_message_dismissed_at = 0
|
||||||
|
|
||||||
def show(self) -> None:
|
def show(self) -> None:
|
||||||
av_player.stop_and_clear_queue()
|
av_player.stop_and_clear_queue()
|
||||||
|
@ -87,6 +98,13 @@ class DeckBrowser:
|
||||||
self._handle_drag_and_drop(int(source), int(target or 0))
|
self._handle_drag_and_drop(int(source), int(target or 0))
|
||||||
elif cmd == "collapse":
|
elif cmd == "collapse":
|
||||||
self._collapse(int(arg))
|
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
|
return False
|
||||||
|
|
||||||
def _selDeck(self, did: str) -> None:
|
def _selDeck(self, did: str) -> None:
|
||||||
|
@ -121,7 +139,7 @@ class DeckBrowser:
|
||||||
)
|
)
|
||||||
gui_hooks.deck_browser_will_render_content(self, content)
|
gui_hooks.deck_browser_will_render_content(self, content)
|
||||||
self.web.stdHtml(
|
self.web.stdHtml(
|
||||||
self._body % content.__dict__,
|
self._v1_upgrade_message() + self._body % content.__dict__,
|
||||||
css=["css/deckbrowser.css"],
|
css=["css/deckbrowser.css"],
|
||||||
js=[
|
js=[
|
||||||
"js/vendor/jquery.min.js",
|
"js/vendor/jquery.min.js",
|
||||||
|
@ -333,3 +351,45 @@ class DeckBrowser:
|
||||||
|
|
||||||
def _onShared(self) -> None:
|
def _onShared(self) -> None:
|
||||||
openLink(f"{aqt.appShared}decks/")
|
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>
|
<x>0</x>
|
||||||
<y>0</y>
|
<y>0</y>
|
||||||
<width>640</width>
|
<width>640</width>
|
||||||
<height>406</height>
|
<height>419</height>
|
||||||
</rect>
|
</rect>
|
||||||
</property>
|
</property>
|
||||||
<property name="windowTitle">
|
<property name="windowTitle">
|
||||||
|
@ -199,16 +199,9 @@
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
<item>
|
<item>
|
||||||
<widget class="QCheckBox" name="newSched">
|
<widget class="QCheckBox" name="legacy_timezone">
|
||||||
<property name="text">
|
<property name="text">
|
||||||
<string>PREFERENCES_ANKI_21_SCHEDULER_BETA</string>
|
<string>PREFERENCES_LEGACY_TIMEZONE_HANDLING</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>
|
|
||||||
</property>
|
</property>
|
||||||
</widget>
|
</widget>
|
||||||
</item>
|
</item>
|
||||||
|
@ -604,8 +597,7 @@
|
||||||
<tabstop>showEstimates</tabstop>
|
<tabstop>showEstimates</tabstop>
|
||||||
<tabstop>showProgress</tabstop>
|
<tabstop>showProgress</tabstop>
|
||||||
<tabstop>dayLearnFirst</tabstop>
|
<tabstop>dayLearnFirst</tabstop>
|
||||||
<tabstop>newSched</tabstop>
|
<tabstop>legacy_timezone</tabstop>
|
||||||
<tabstop>new_timezone</tabstop>
|
|
||||||
<tabstop>newSpread</tabstop>
|
<tabstop>newSpread</tabstop>
|
||||||
<tabstop>dayOffset</tabstop>
|
<tabstop>dayOffset</tabstop>
|
||||||
<tabstop>lrnCutoff</tabstop>
|
<tabstop>lrnCutoff</tabstop>
|
||||||
|
|
|
@ -8,7 +8,6 @@ from aqt.qt import *
|
||||||
from aqt.utils import (
|
from aqt.utils import (
|
||||||
TR,
|
TR,
|
||||||
HelpPage,
|
HelpPage,
|
||||||
askUser,
|
|
||||||
disable_help_button,
|
disable_help_button,
|
||||||
openHelp,
|
openHelp,
|
||||||
showInfo,
|
showInfo,
|
||||||
|
@ -124,10 +123,9 @@ class Preferences(QDialog):
|
||||||
|
|
||||||
if s.scheduler_version < 2:
|
if s.scheduler_version < 2:
|
||||||
f.dayLearnFirst.setVisible(False)
|
f.dayLearnFirst.setVisible(False)
|
||||||
f.new_timezone.setVisible(False)
|
f.legacy_timezone.setVisible(False)
|
||||||
else:
|
else:
|
||||||
f.newSched.setChecked(True)
|
f.legacy_timezone.setChecked(not s.new_timezone)
|
||||||
f.new_timezone.setChecked(s.new_timezone)
|
|
||||||
|
|
||||||
def setup_video_driver(self) -> None:
|
def setup_video_driver(self) -> None:
|
||||||
self.video_drivers = VideoDriver.all_for_platform()
|
self.video_drivers = VideoDriver.all_for_platform()
|
||||||
|
@ -163,33 +161,12 @@ class Preferences(QDialog):
|
||||||
s.learn_ahead_secs = f.lrnCutoff.value() * 60
|
s.learn_ahead_secs = f.lrnCutoff.value() * 60
|
||||||
s.day_learn_first = f.dayLearnFirst.isChecked()
|
s.day_learn_first = f.dayLearnFirst.isChecked()
|
||||||
s.rollover = f.dayOffset.value()
|
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.mw.col.set_preferences(self.prefs)
|
||||||
|
|
||||||
self._updateSchedVer(f.newSched.isChecked())
|
|
||||||
d.setMod()
|
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
|
# Network
|
||||||
######################################################################
|
######################################################################
|
||||||
|
|
||||||
|
|
|
@ -118,6 +118,7 @@ service BackendService {
|
||||||
rpc GetNextCardStates(CardID) returns (NextCardStates);
|
rpc GetNextCardStates(CardID) returns (NextCardStates);
|
||||||
rpc DescribeNextStates(NextCardStates) returns (StringList);
|
rpc DescribeNextStates(NextCardStates) returns (StringList);
|
||||||
rpc AnswerCard(AnswerCardIn) returns (Empty);
|
rpc AnswerCard(AnswerCardIn) returns (Empty);
|
||||||
|
rpc UpgradeScheduler(Empty) returns (Empty);
|
||||||
|
|
||||||
// stats
|
// stats
|
||||||
|
|
||||||
|
@ -997,7 +998,9 @@ message CollectionSchedulingSettings {
|
||||||
NEW_FIRST = 2;
|
NEW_FIRST = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// read only
|
||||||
uint32 scheduler_version = 1;
|
uint32 scheduler_version = 1;
|
||||||
|
|
||||||
uint32 rollover = 2;
|
uint32 rollover = 2;
|
||||||
uint32 learn_ahead_secs = 3;
|
uint32 learn_ahead_secs = 3;
|
||||||
NewReviewMix new_review_mix = 4;
|
NewReviewMix new_review_mix = 4;
|
||||||
|
|
|
@ -711,6 +711,11 @@ impl BackendService for Backend {
|
||||||
.map(Into::into)
|
.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
|
// statistics
|
||||||
//-----------------------------------------------
|
//-----------------------------------------------
|
||||||
|
|
||||||
|
|
|
@ -216,7 +216,7 @@ impl Collection {
|
||||||
return Err(AnkiError::DeckIsFiltered);
|
return Err(AnkiError::DeckIsFiltered);
|
||||||
}
|
}
|
||||||
self.storage.set_search_table_to_card_ids(cards, false)?;
|
self.storage.set_search_table_to_card_ids(cards, false)?;
|
||||||
let sched = self.sched_ver();
|
let sched = self.scheduler_version();
|
||||||
let usn = self.usn()?;
|
let usn = self.usn()?;
|
||||||
self.transact(None, |col| {
|
self.transact(None, |col| {
|
||||||
for mut card in col.storage.all_searched_cards()? {
|
for mut card in col.storage.all_searched_cards()? {
|
||||||
|
|
|
@ -254,11 +254,16 @@ impl Collection {
|
||||||
self.set_config(ConfigKey::NextNewCardPosition, &pos)
|
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)
|
self.get_config_optional(ConfigKey::SchedulerVersion)
|
||||||
.unwrap_or(SchedulerVersion::V1)
|
.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 {
|
pub(crate) fn learn_ahead_secs(&self) -> u32 {
|
||||||
self.get_config_optional(ConfigKey::LearnAheadSecs)
|
self.get_config_optional(ConfigKey::LearnAheadSecs)
|
||||||
.unwrap_or(1200)
|
.unwrap_or(1200)
|
||||||
|
|
|
@ -14,10 +14,7 @@ use crate::{
|
||||||
};
|
};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
use slog::debug;
|
use slog::debug;
|
||||||
use std::{
|
use std::{collections::HashSet, sync::Arc};
|
||||||
collections::{HashMap, HashSet},
|
|
||||||
sync::Arc,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Debug, Default, PartialEq)]
|
#[derive(Debug, Default, PartialEq)]
|
||||||
pub struct CheckDatabaseOutput {
|
pub struct CheckDatabaseOutput {
|
||||||
|
@ -200,12 +197,7 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_filtered_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {
|
fn check_filtered_cards(&mut self, out: &mut CheckDatabaseOutput) -> Result<()> {
|
||||||
let decks: HashMap<_, _> = self
|
let decks = self.storage.get_decks_map()?;
|
||||||
.storage
|
|
||||||
.get_all_decks()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.id, d))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut wrong = 0;
|
let mut wrong = 0;
|
||||||
for (cid, did) in self.storage.all_filtered_cards_by_deck()? {
|
for (cid, did) in self.storage.all_filtered_cards_by_deck()? {
|
||||||
|
|
|
@ -19,7 +19,11 @@ impl Collection {
|
||||||
learn_cutoff: u32,
|
learn_cutoff: u32,
|
||||||
limit_to: Option<&str>,
|
limit_to: Option<&str>,
|
||||||
) -> Result<HashMap<DeckID, DueCounts>> {
|
) -> Result<HashMap<DeckID, DueCounts>> {
|
||||||
self.storage
|
self.storage.due_counts(
|
||||||
.due_counts(self.sched_ver(), days_elapsed, learn_cutoff, limit_to)
|
self.scheduler_version(),
|
||||||
|
days_elapsed,
|
||||||
|
learn_cutoff,
|
||||||
|
limit_to,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -224,12 +224,7 @@ impl Collection {
|
||||||
let names = self.storage.get_all_deck_names()?;
|
let names = self.storage.get_all_deck_names()?;
|
||||||
let mut tree = deck_names_to_tree(names);
|
let mut tree = deck_names_to_tree(names);
|
||||||
|
|
||||||
let decks_map: HashMap<_, _> = self
|
let decks_map = self.storage.get_decks_map()?;
|
||||||
.storage
|
|
||||||
.get_all_decks()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.id, d))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
add_collapsed_and_filtered(&mut tree, &decks_map, now.is_none());
|
add_collapsed_and_filtered(&mut tree, &decks_map, now.is_none());
|
||||||
if self.default_deck_is_empty()? {
|
if self.default_deck_is_empty()? {
|
||||||
|
@ -247,12 +242,7 @@ impl Collection {
|
||||||
let days_elapsed = self.timing_for_timestamp(now)?.days_elapsed;
|
let days_elapsed = self.timing_for_timestamp(now)?.days_elapsed;
|
||||||
let learn_cutoff = (now.0 as u32) + self.learn_ahead_secs();
|
let learn_cutoff = (now.0 as u32) + self.learn_ahead_secs();
|
||||||
let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?;
|
let counts = self.due_counts(days_elapsed, learn_cutoff, limit)?;
|
||||||
let dconf: HashMap<_, _> = self
|
let dconf = self.storage.get_deck_config_map()?;
|
||||||
.storage
|
|
||||||
.all_deck_config()?
|
|
||||||
.into_iter()
|
|
||||||
.map(|d| (d.id, d))
|
|
||||||
.collect();
|
|
||||||
add_counts(&mut tree, &counts);
|
add_counts(&mut tree, &counts);
|
||||||
apply_limits(
|
apply_limits(
|
||||||
&mut tree,
|
&mut tree,
|
||||||
|
|
|
@ -185,7 +185,7 @@ impl Collection {
|
||||||
|
|
||||||
// Unlike the old Python code, this also marks the cards as modified.
|
// Unlike the old Python code, this also marks the cards as modified.
|
||||||
fn return_cards_to_home_deck(&mut self, cids: &[CardID]) -> Result<()> {
|
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()?;
|
let usn = self.usn()?;
|
||||||
for cid in cids {
|
for cid in cids {
|
||||||
if let Some(mut card) = self.storage.get_card(*cid)? {
|
if let Some(mut card) = self.storage.get_card(*cid)? {
|
||||||
|
@ -208,7 +208,7 @@ impl Collection {
|
||||||
let ctx = DeckFilterContext {
|
let ctx = DeckFilterContext {
|
||||||
target_deck: did,
|
target_deck: did,
|
||||||
config,
|
config,
|
||||||
scheduler: self.sched_ver(),
|
scheduler: self.scheduler_version(),
|
||||||
usn: self.usn()?,
|
usn: self.usn()?,
|
||||||
today: self.timing_today()?.days_elapsed,
|
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 {
|
pub(crate) fn cloze(i18n: &I18n) -> NoteType {
|
||||||
let mut nt = NoteType::default();
|
let mut nt = NoteType {
|
||||||
nt.name = i18n.tr(TR::NotetypesClozeName).into();
|
name: i18n.tr(TR::NotetypesClozeName).into(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
let text = i18n.tr(TR::NotetypesTextField);
|
let text = i18n.tr(TR::NotetypesTextField);
|
||||||
nt.add_field(text.as_ref());
|
nt.add_field(text.as_ref());
|
||||||
let back_extra = i18n.tr(TR::NotetypesBackExtraField);
|
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 {
|
if let Some(sched) = prefs.sched {
|
||||||
self.set_collection_scheduling_settings(sched)?;
|
self.set_collection_scheduling_settings(sched)?;
|
||||||
}
|
}
|
||||||
|
@ -28,7 +28,7 @@ impl Collection {
|
||||||
|
|
||||||
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> {
|
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> {
|
||||||
Ok(CollectionSchedulingSettings {
|
Ok(CollectionSchedulingSettings {
|
||||||
scheduler_version: match self.sched_ver() {
|
scheduler_version: match self.scheduler_version() {
|
||||||
crate::config::SchedulerVersion::V1 => 1,
|
crate::config::SchedulerVersion::V1 => 1,
|
||||||
crate::config::SchedulerVersion::V2 => 2,
|
crate::config::SchedulerVersion::V2 => 2,
|
||||||
},
|
},
|
||||||
|
@ -48,7 +48,7 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_collection_scheduling_settings(
|
pub(crate) fn set_collection_scheduling_settings(
|
||||||
&self,
|
&mut self,
|
||||||
settings: CollectionSchedulingSettings,
|
settings: CollectionSchedulingSettings,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let s = settings;
|
let s = settings;
|
||||||
|
@ -79,7 +79,6 @@ impl Collection {
|
||||||
self.set_creation_utc_offset(None)?;
|
self.set_creation_utc_offset(None)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fixme: currently scheduler change unhandled
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,8 +4,8 @@
|
||||||
pub use crate::{
|
pub use crate::{
|
||||||
card::{Card, CardID},
|
card::{Card, CardID},
|
||||||
collection::Collection,
|
collection::Collection,
|
||||||
deckconf::DeckConfID,
|
deckconf::{DeckConf, DeckConfID},
|
||||||
decks::DeckID,
|
decks::{Deck, DeckID, DeckKind},
|
||||||
err::{AnkiError, Result},
|
err::{AnkiError, Result},
|
||||||
i18n::{tr_args, tr_strs, TR},
|
i18n::{tr_args, tr_strs, TR},
|
||||||
notes::{Note, NoteID},
|
notes::{Note, NoteID},
|
||||||
|
|
|
@ -103,7 +103,7 @@ impl Collection {
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use pb::bury_or_suspend_cards_in::Mode;
|
use pb::bury_or_suspend_cards_in::Mode;
|
||||||
let usn = self.usn()?;
|
let usn = self.usn()?;
|
||||||
let sched = self.sched_ver();
|
let sched = self.scheduler_version();
|
||||||
|
|
||||||
for original in self.storage.all_searched_cards()? {
|
for original in self.storage.all_searched_cards()? {
|
||||||
let mut card = original.clone();
|
let mut card = original.clone();
|
||||||
|
|
|
@ -12,6 +12,7 @@ pub mod new;
|
||||||
mod reviews;
|
mod reviews;
|
||||||
pub mod states;
|
pub mod states;
|
||||||
pub mod timespan;
|
pub mod timespan;
|
||||||
|
mod upgrade;
|
||||||
|
|
||||||
use chrono::FixedOffset;
|
use chrono::FixedOffset;
|
||||||
use cutoff::{
|
use cutoff::{
|
||||||
|
@ -32,7 +33,7 @@ impl Collection {
|
||||||
pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result<SchedTimingToday> {
|
pub(crate) fn timing_for_timestamp(&self, now: TimestampSecs) -> Result<SchedTimingToday> {
|
||||||
let current_utc_offset = self.local_utc_offset_for_user()?;
|
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::V1 => None,
|
||||||
SchedulerVersion::V2 => {
|
SchedulerVersion::V2 => {
|
||||||
let configured_rollover = self.get_v2_rollover();
|
let configured_rollover = self.get_v2_rollover();
|
||||||
|
@ -89,7 +90,7 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rollover_for_current_scheduler(&self) -> Result<u8> {
|
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(
|
SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp(
|
||||||
self.storage.creation_stamp()?.0,
|
self.storage.creation_stamp()?.0,
|
||||||
)),
|
)),
|
||||||
|
@ -98,7 +99,7 @@ impl Collection {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_rollover_for_current_scheduler(&self, hour: u8) -> Result<()> {
|
pub(crate) fn set_rollover_for_current_scheduler(&self, hour: u8) -> Result<()> {
|
||||||
match self.sched_ver() {
|
match self.scheduler_version() {
|
||||||
SchedulerVersion::V1 => {
|
SchedulerVersion::V1 => {
|
||||||
self.storage
|
self.storage
|
||||||
.set_creation_stamp(TimestampSecs(v1_creation_date_adjusted_to_hour(
|
.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
|
/// Place the matched card ids into a temporary 'search_cids' table
|
||||||
/// instead of returning them. Use clear_searched_cards() to remove it.
|
/// 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 top_node = Node::Group(parse(search)?);
|
||||||
let writer = SqlWriter::new(self);
|
let writer = SqlWriter::new(self);
|
||||||
let want_order = mode != SortMode::NoOrder;
|
let want_order = mode != SortMode::NoOrder;
|
||||||
|
@ -107,9 +112,11 @@ impl Collection {
|
||||||
}
|
}
|
||||||
let sql = format!("insert into search_cids {}", sql);
|
let sql = format!("insert into search_cids {}", sql);
|
||||||
|
|
||||||
self.storage.db.prepare(&sql)?.execute(&args)?;
|
self.storage
|
||||||
|
.db
|
||||||
Ok(())
|
.prepare(&sql)?
|
||||||
|
.execute(&args)
|
||||||
|
.map_err(Into::into)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the sort mode is based on a config setting, look it up.
|
/// If the sort mode is based on a config setting, look it up.
|
||||||
|
|
|
@ -42,7 +42,7 @@ impl Collection {
|
||||||
revlog,
|
revlog,
|
||||||
days_elapsed: timing.days_elapsed,
|
days_elapsed: timing.days_elapsed,
|
||||||
next_day_at_secs: timing.next_day_at as u32,
|
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,
|
local_offset_secs: local_offset_secs as i32,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,6 +66,14 @@ impl SqliteStorage {
|
||||||
.collect()
|
.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 (::)
|
/// Get all deck names in sorted, human-readable form (::)
|
||||||
pub(crate) fn get_all_deck_names(&self) -> Result<Vec<(DeckID, String)>> {
|
pub(crate) fn get_all_deck_names(&self) -> Result<Vec<(DeckID, String)>> {
|
||||||
self.db
|
self.db
|
||||||
|
|
|
@ -30,6 +30,14 @@ impl SqliteStorage {
|
||||||
.collect()
|
.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>> {
|
pub(crate) fn get_deck_config(&self, dcid: DeckConfID) -> Result<Option<DeckConf>> {
|
||||||
self.db
|
self.db
|
||||||
.prepare_cached(concat!(include_str!("get.sql"), " where id = ?"))?
|
.prepare_cached(concat!(include_str!("get.sql"), " where id = ?"))?
|
||||||
|
|
|
@ -133,4 +133,10 @@ impl SqliteStorage {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.map_err(Into::into)
|
.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 a new issue