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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -75,3 +75,13 @@ body {
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
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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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