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:
Damien Elmes 2021-02-21 15:50:41 +10:00
parent 83c8d53da2
commit 5ae66af5d2
33 changed files with 367 additions and 258 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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 = {}

View file

@ -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)")

View file

@ -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

View file

@ -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

View file

@ -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():

View file

@ -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

View file

@ -75,3 +75,13 @@ body {
filter: invert(180); filter: invert(180);
} }
} }
.callout {
background: var(--medium-border);
padding: 1em;
margin: 1em;
div {
margin: 1em;
}
}

View file

@ -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()

View file

@ -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>

View file

@ -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
###################################################################### ######################################################################

View file

@ -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;

View file

@ -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
//----------------------------------------------- //-----------------------------------------------

View file

@ -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()? {

View file

@ -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)

View file

@ -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()? {

View file

@ -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,
)
} }
} }

View file

@ -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,

View file

@ -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,
}; };

View file

@ -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);

View file

@ -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(())
} }
} }

View file

@ -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},

View file

@ -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();

View file

@ -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
View 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);
}
}

View file

@ -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.

View file

@ -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,
}) })
} }

View file

@ -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

View file

@ -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 = ?"))?

View file

@ -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)
}
} }

View file

@ -0,0 +1,4 @@
UPDATE revlog
SET ease = ease + 1
WHERE ease IN (2, 3)
AND type IN (0, 2);