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