handle scheduling preferences in the backend

This commit is contained in:
Damien Elmes 2020-05-11 18:35:15 +10:00
parent 6ef8d976eb
commit 9317cee9ba
13 changed files with 305 additions and 90 deletions

View file

@ -95,6 +95,8 @@ message BackendInput {
AddNoteTagsIn add_note_tags = 81;
UpdateNoteTagsIn update_note_tags = 82;
int32 set_local_minutes_west = 83;
Empty get_preferences = 84;
Preferences set_preferences = 85;
}
}
@ -169,6 +171,8 @@ message BackendOutput {
uint32 add_note_tags = 81;
uint32 update_note_tags = 82;
Empty set_local_minutes_west = 83;
Preferences get_preferences = 84;
Empty set_preferences = 85;
BackendError error = 2047;
}
@ -747,3 +751,27 @@ message UpdateNoteTagsIn {
message CheckDatabaseOut {
repeated string problems = 1;
}
message CollectionSchedulingSettings {
enum NewReviewMix {
DISTRIBUTE = 0;
REVIEWS_FIRST = 1;
NEW_FIRST = 2;
}
uint32 scheduler_version = 1;
uint32 rollover = 2;
uint32 learn_ahead_secs = 3;
NewReviewMix new_review_mix = 4;
bool show_remaining_due_counts = 5;
bool show_intervals_on_buttons = 6;
uint32 time_limit_secs = 7;
// v2 only
bool new_timezone = 8;
bool day_learn_first = 9;
}
message Preferences {
CollectionSchedulingSettings sched = 1;
}

View file

@ -38,7 +38,6 @@ from anki.utils import devMode, ids2str, intTime, joinFields
class _Collection:
db: Optional[DBProxy]
sched: Union[V1Scheduler, V2Scheduler]
crt: int
mod: int
scm: int
_usn: int
@ -132,11 +131,19 @@ class _Collection:
##########################################################################
def load(self) -> None:
(self.crt, self.mod, self.scm, self._usn, self.ls,) = self.db.first(
(self.mod, self.scm, self._usn, self.ls,) = self.db.first(
"""
select crt, mod, scm, usn, ls from col"""
select mod, scm, usn, ls from col"""
)
def _get_crt(self) -> int:
return self.db.scalar("select crt from col")
def _set_crt(self, val: int) -> None:
self.db.execute("update col set crt=?", val)
crt = property(_get_crt, _set_crt)
def setMod(self) -> None:
"""Mark DB modified.
@ -149,8 +156,7 @@ is only necessary if you modify properties of this object."""
self.mod = intTime(1000) if mod is None else mod
self.db.execute(
"""update col set
crt=?, mod=?, scm=?, usn=?, ls=?""",
self.crt,
mod=?, scm=?, usn=?, ls=?""",
self.mod,
self.scm,
self._usn,

View file

@ -782,6 +782,14 @@ class RustBackend:
def set_local_minutes_west(self, mins: int) -> None:
self._run_command(pb.BackendInput(set_local_minutes_west=mins))
def get_preferences(self) -> pb.Preferences:
return self._run_command(
pb.BackendInput(get_preferences=pb.Empty())
).get_preferences
def set_preferences(self, prefs: pb.Preferences) -> None:
self._run_command(pb.BackendInput(set_preferences=prefs))
def translate_string_in(
key: TR, **kwargs: Union[str, int, float]

View file

@ -1377,26 +1377,6 @@ where id = ?
def _timing_today(self) -> SchedTimingToday:
return self.col.backend.sched_timing_today()
# New timezone handling - GUI helpers
##########################################################################
def new_timezone_enabled(self) -> bool:
return self.col.conf.get("creationOffset") is not None
def set_creation_offset(self):
"""Save the UTC west offset at the time of creation into the DB.
Once stored, this activates the new timezone handling code.
"""
mins_west = self.col.backend.local_minutes_west(self.col.crt)
self.col.conf["creationOffset"] = mins_west
self.col.setMod()
def clear_creation_offset(self):
if "creationOffset" in self.col.conf:
del self.col.conf["creationOffset"]
self.col.setMod()
# Deck finished state
##########################################################################

View file

@ -2,9 +2,6 @@
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import datetime
import time
import anki.lang
import aqt
from anki.lang import _
@ -24,6 +21,7 @@ class Preferences(QDialog):
self.form.buttonBox.button(QDialogButtonBox.Close).setAutoDefault(False)
qconnect(self.form.buttonBox.helpRequested, lambda: openHelp("profileprefs"))
self.silentlyClose = True
self.prefs = self.mw.col.backend.get_preferences()
self.setupLang()
self.setupCollection()
self.setupNetwork()
@ -81,25 +79,31 @@ class Preferences(QDialog):
f = self.form
qc = self.mw.col.conf
self._setupDayCutoff()
if isMac:
f.hwAccel.setVisible(False)
else:
f.hwAccel.setChecked(self.mw.pm.glMode() != "software")
f.lrnCutoff.setValue(qc["collapseTime"] / 60.0)
f.timeLimit.setValue(qc["timeLim"] / 60.0)
f.showEstimates.setChecked(qc["estTimes"])
f.showProgress.setChecked(qc["dueCounts"])
f.newSpread.addItems(list(c.newCardSchedulingLabels().values()))
f.newSpread.setCurrentIndex(qc["newSpread"])
f.useCurrent.setCurrentIndex(int(not qc.get("addToCur", True)))
f.dayLearnFirst.setChecked(qc.get("dayLearnFirst", False))
if self.mw.col.schedVer() != 2:
s = self.prefs.sched
f.lrnCutoff.setValue(s.learn_ahead_secs / 60.0)
f.timeLimit.setValue(s.time_limit_secs / 60.0)
f.showEstimates.setChecked(s.show_intervals_on_buttons)
f.showProgress.setChecked(s.show_remaining_due_counts)
f.newSpread.setCurrentIndex(s.new_review_mix)
f.dayLearnFirst.setChecked(s.day_learn_first)
f.dayOffset.setValue(s.rollover)
if s.scheduler_version < 2:
f.dayLearnFirst.setVisible(False)
f.new_timezone.setVisible(False)
else:
f.newSched.setChecked(True)
f.new_timezone.setChecked(self.mw.col.sched.new_timezone_enabled())
f.new_timezone.setChecked(s.new_timezone)
def updateCollection(self):
f = self.form
@ -116,22 +120,22 @@ class Preferences(QDialog):
showInfo(_("Changes will take effect when you restart Anki."))
qc = d.conf
qc["dueCounts"] = f.showProgress.isChecked()
qc["estTimes"] = f.showEstimates.isChecked()
qc["newSpread"] = f.newSpread.currentIndex()
qc["timeLim"] = f.timeLimit.value() * 60
qc["collapseTime"] = f.lrnCutoff.value() * 60
qc["addToCur"] = not f.useCurrent.currentIndex()
qc["dayLearnFirst"] = f.dayLearnFirst.isChecked()
self._updateDayCutoff()
if self.mw.col.schedVer() != 1:
was_enabled = self.mw.col.sched.new_timezone_enabled()
is_enabled = f.new_timezone.isChecked()
if was_enabled != is_enabled:
if is_enabled:
self.mw.col.sched.set_creation_offset()
else:
self.mw.col.sched.clear_creation_offset()
s = self.prefs.sched
s.show_remaining_due_counts = f.showProgress.isChecked()
s.show_intervals_on_buttons = f.showEstimates.isChecked()
s.new_review_mix = f.newSpread.currentIndex()
s.time_limit_secs = f.timeLimit.value() * 60
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()
# if moving this, make sure scheduler change is moved to Rust or
# happens afterwards
self.mw.col.backend.set_preferences(self.prefs)
self._updateSchedVer(f.newSched.isChecked())
d.setMod()
@ -157,37 +161,6 @@ class Preferences(QDialog):
else:
self.mw.col.changeSchedulerVer(1)
# Day cutoff
######################################################################
def _setupDayCutoff(self):
if self.mw.col.schedVer() == 2:
self._setupDayCutoffV2()
else:
self._setupDayCutoffV1()
def _setupDayCutoffV1(self):
self.startDate = datetime.datetime.fromtimestamp(self.mw.col.crt)
self.form.dayOffset.setValue(self.startDate.hour)
def _setupDayCutoffV2(self):
self.form.dayOffset.setValue(self.mw.col.conf.get("rollover", 4))
def _updateDayCutoff(self):
if self.mw.col.schedVer() == 2:
self._updateDayCutoffV2()
else:
self._updateDayCutoffV1()
def _updateDayCutoffV1(self):
hrs = self.form.dayOffset.value()
old = self.startDate
date = datetime.datetime(old.year, old.month, old.day, hrs)
self.mw.col.crt = int(time.mktime(date.timetuple()))
def _updateDayCutoffV2(self):
self.mw.col.conf["rollover"] = self.form.dayOffset.value()
# Network
######################################################################

View file

@ -60,3 +60,5 @@ check_untyped_defs=true
check_untyped_defs=true
[mypy-aqt.clayout]
check_untyped_defs=true
[mypy-aqt.preferences]
check_untyped_defs=true

View file

@ -368,6 +368,11 @@ impl Backend {
self.set_local_mins_west(mins)?;
pb::Empty {}
}),
Value::GetPreferences(_) => OValue::GetPreferences(self.get_preferences()?),
Value::SetPreferences(prefs) => OValue::SetPreferences({
self.set_preferences(prefs)?;
pb::Empty {}
}),
})
}
@ -820,7 +825,7 @@ impl Backend {
let val: JsonValue = serde_json::from_slice(&val)?;
col.set_config(input.key.as_str(), &val)
}
pb::set_config_json::Op::Remove(_) => col.remove_config(&input.key),
pb::set_config_json::Op::Remove(_) => col.remove_config(input.key.as_str()),
}
} else {
Err(AnkiError::invalid_input("no op received"))
@ -1115,6 +1120,14 @@ impl Backend {
fn set_local_mins_west(&self, mins: i32) -> Result<()> {
self.with_col(|col| col.transact(None, |col| col.set_local_mins_west(mins)))
}
fn get_preferences(&self) -> Result<pb::Preferences> {
self.with_col(|col| col.get_preferences())
}
fn set_preferences(&self, prefs: pb::Preferences) -> Result<()> {
self.with_col(|col| col.transact(None, |col| col.set_preferences(prefs)))
}
}
fn to_nids(ids: Vec<i64>) -> Vec<NoteID> {

View file

@ -44,6 +44,11 @@ pub(crate) enum ConfigKey {
SchedulerVersion,
LearnAheadSecs,
NormalizeNoteText,
ShowRemainingDueCountsInStudy,
ShowIntervalsAboveAnswerButtons,
NewReviewMix,
AnswerTimeLimitSecs,
ShowDayLearningCardsFirst,
}
#[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy)]
#[repr(u8)]
@ -66,6 +71,11 @@ impl From<ConfigKey> for &'static str {
ConfigKey::SchedulerVersion => "schedVer",
ConfigKey::LearnAheadSecs => "collapseTime",
ConfigKey::NormalizeNoteText => "normalize_note_text",
ConfigKey::ShowRemainingDueCountsInStudy => "dueCounts",
ConfigKey::ShowIntervalsAboveAnswerButtons => "estTimes",
ConfigKey::NewReviewMix => "newSpread",
ConfigKey::AnswerTimeLimitSecs => "timeLim",
ConfigKey::ShowDayLearningCardsFirst => "dayLearnFirst",
}
}
}
@ -108,8 +118,11 @@ impl Collection {
.set_config_value(key.into(), val, self.usn()?, TimestampSecs::now())
}
pub(crate) fn remove_config(&self, key: &str) -> Result<()> {
self.storage.remove_config(key)
pub(crate) fn remove_config<'a, K>(&self, key: K) -> Result<()>
where
K: Into<&'a str>,
{
self.storage.remove_config(key.into())
}
pub(crate) fn get_browser_sort_kind(&self) -> SortKind {
@ -130,6 +143,14 @@ impl Collection {
self.get_config_optional(ConfigKey::CreationOffset)
}
pub(crate) fn set_creation_mins_west(&self, mins: Option<i32>) -> Result<()> {
if let Some(mins) = mins {
self.set_config(ConfigKey::CreationOffset, &mins)
} else {
self.remove_config(ConfigKey::CreationOffset)
}
}
pub(crate) fn get_local_mins_west(&self) -> Option<i32> {
self.get_config_optional(ConfigKey::LocalOffset)
}
@ -138,11 +159,15 @@ impl Collection {
self.set_config(ConfigKey::LocalOffset, &mins)
}
pub(crate) fn get_rollover(&self) -> Option<u8> {
pub(crate) fn get_v2_rollover(&self) -> Option<u8> {
self.get_config_optional::<u8, _>(ConfigKey::Rollover)
.map(|r| r.min(23))
}
pub(crate) fn set_v2_rollover(&self, hour: u32) -> Result<()> {
self.set_config(ConfigKey::Rollover, &hour)
}
#[allow(dead_code)]
pub(crate) fn get_current_notetype_id(&self) -> Option<NoteTypeID> {
self.get_config_optional(ConfigKey::CurrentNoteTypeID)
@ -175,11 +200,63 @@ impl Collection {
.unwrap_or(1200)
}
pub(crate) fn set_learn_ahead_secs(&self, secs: u32) -> Result<()> {
self.set_config(ConfigKey::LearnAheadSecs, &secs)
}
/// This is a stop-gap solution until we can decouple searching from canonical storage.
pub(crate) fn normalize_note_text(&self) -> bool {
self.get_config_optional(ConfigKey::NormalizeNoteText)
.unwrap_or(true)
}
pub(crate) fn get_new_review_mix(&self) -> NewReviewMix {
match self.get_config_default::<u8, _>(ConfigKey::NewReviewMix) {
1 => NewReviewMix::ReviewsFirst,
2 => NewReviewMix::NewFirst,
_ => NewReviewMix::Mix,
}
}
pub(crate) fn set_new_review_mix(&self, mix: NewReviewMix) -> Result<()> {
self.set_config(ConfigKey::NewReviewMix, &(mix as u8))
}
pub(crate) fn get_show_due_counts(&self) -> bool {
self.get_config_optional(ConfigKey::ShowRemainingDueCountsInStudy)
.unwrap_or(true)
}
pub(crate) fn set_show_due_counts(&self, on: bool) -> Result<()> {
self.set_config(ConfigKey::ShowRemainingDueCountsInStudy, &on)
}
pub(crate) fn get_show_intervals_above_buttons(&self) -> bool {
self.get_config_optional(ConfigKey::ShowIntervalsAboveAnswerButtons)
.unwrap_or(true)
}
pub(crate) fn set_show_intervals_above_buttons(&self, on: bool) -> Result<()> {
self.set_config(ConfigKey::ShowIntervalsAboveAnswerButtons, &on)
}
pub(crate) fn get_answer_time_limit_secs(&self) -> u32 {
self.get_config_optional(ConfigKey::AnswerTimeLimitSecs)
.unwrap_or_default()
}
pub(crate) fn set_answer_time_limit_secs(&self, secs: u32) -> Result<()> {
self.set_config(ConfigKey::AnswerTimeLimitSecs, &secs)
}
pub(crate) fn get_day_learn_first(&self) -> bool {
self.get_config_optional(ConfigKey::ShowDayLearningCardsFirst)
.unwrap_or_default()
}
pub(crate) fn set_day_learn_first(&self, on: bool) -> Result<()> {
self.set_config(ConfigKey::ShowDayLearningCardsFirst, &on)
}
}
#[derive(Deserialize, PartialEq, Debug, Clone, Copy)]
@ -212,6 +289,12 @@ impl Default for SortKind {
}
}
pub(crate) enum NewReviewMix {
Mix = 0,
ReviewsFirst = 1,
NewFirst = 2,
}
#[cfg(test)]
mod test {
use super::SortKind;

View file

@ -25,6 +25,7 @@ pub mod log;
pub mod media;
pub mod notes;
pub mod notetype;
mod preferences;
pub mod sched;
pub mod search;
pub mod serde;

85
rslib/src/preferences.rs Normal file
View file

@ -0,0 +1,85 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use crate::{
backend_proto::{
collection_scheduling_settings::NewReviewMix as NewRevMixPB, CollectionSchedulingSettings,
Preferences,
},
collection::Collection,
err::Result,
sched::cutoff::local_minutes_west_for_stamp,
};
impl Collection {
pub fn get_preferences(&self) -> Result<Preferences> {
Ok(Preferences {
sched: Some(self.get_collection_scheduling_settings()?),
})
}
pub fn set_preferences(&self, prefs: Preferences) -> Result<()> {
if let Some(sched) = prefs.sched {
self.set_collection_scheduling_settings(sched)?;
}
Ok(())
}
pub fn get_collection_scheduling_settings(&self) -> Result<CollectionSchedulingSettings> {
Ok(CollectionSchedulingSettings {
scheduler_version: match self.sched_ver() {
crate::config::SchedulerVersion::V1 => 1,
crate::config::SchedulerVersion::V2 => 2,
},
rollover: self.rollover_for_current_scheduler()? as u32,
learn_ahead_secs: self.learn_ahead_secs(),
new_review_mix: match self.get_new_review_mix() {
crate::config::NewReviewMix::Mix => NewRevMixPB::Distribute,
crate::config::NewReviewMix::ReviewsFirst => NewRevMixPB::ReviewsFirst,
crate::config::NewReviewMix::NewFirst => NewRevMixPB::NewFirst,
} as i32,
show_remaining_due_counts: self.get_show_due_counts(),
show_intervals_on_buttons: self.get_show_intervals_above_buttons(),
time_limit_secs: self.get_answer_time_limit_secs(),
new_timezone: self.get_creation_mins_west().is_some(),
day_learn_first: self.get_day_learn_first(),
})
}
pub(crate) fn set_collection_scheduling_settings(
&self,
settings: CollectionSchedulingSettings,
) -> Result<()> {
let s = settings;
self.set_day_learn_first(s.day_learn_first)?;
self.set_answer_time_limit_secs(s.time_limit_secs)?;
self.set_show_due_counts(s.show_remaining_due_counts)?;
self.set_show_intervals_above_buttons(s.show_intervals_on_buttons)?;
self.set_learn_ahead_secs(s.learn_ahead_secs)?;
self.set_new_review_mix(match s.new_review_mix() {
NewRevMixPB::Distribute => crate::config::NewReviewMix::Mix,
NewRevMixPB::NewFirst => crate::config::NewReviewMix::NewFirst,
NewRevMixPB::ReviewsFirst => crate::config::NewReviewMix::ReviewsFirst,
})?;
let created = self.storage.creation_stamp()?;
if self.rollover_for_current_scheduler()? != s.rollover as u8 {
self.set_rollover_for_current_scheduler(s.rollover as u8)?;
}
if s.new_timezone {
if self.get_creation_mins_west().is_none() {
self.set_creation_mins_west(Some(local_minutes_west_for_stamp(created.0)))?;
}
} else {
self.set_creation_mins_west(None)?;
}
// fixme: currently scheduler change unhandled
Ok(())
}
}

View file

@ -105,6 +105,14 @@ fn v1_creation_date_inner(now: TimestampSecs, mins_west: i32) -> i64 {
}
}
pub(crate) fn v1_creation_date_adjusted_to_hour(crt: i64, hour: u8) -> i64 {
Local
.timestamp(crt, 0)
.date()
.and_hms(hour as u32, 0, 0)
.timestamp()
}
fn sched_timing_today_v1(crt: i64, now: i64) -> SchedTimingToday {
let days_elapsed = (now - crt) / 86_400;
let next_day_at = crt + (days_elapsed + 1) * 86_400;
@ -376,5 +384,10 @@ mod test {
v1_creation_date_inner(now, AEST_MINS_WEST),
offset.ymd(2020, 05, 9).and_hms(4, 0, 0).timestamp()
);
let crt = v1_creation_date_inner(now, AEST_MINS_WEST);
assert_eq!(crt, v1_creation_date_adjusted_to_hour(crt, 4));
assert_eq!(crt + 3600, v1_creation_date_adjusted_to_hour(crt, 5));
assert_eq!(crt - 3600 * 4, v1_creation_date_adjusted_to_hour(crt, 0));
}
}

View file

@ -8,7 +8,10 @@ use crate::{
pub mod cutoff;
pub mod timespan;
use cutoff::{sched_timing_today, v1_rollover_from_creation_stamp, SchedTimingToday};
use cutoff::{
sched_timing_today, v1_creation_date_adjusted_to_hour, v1_rollover_from_creation_stamp,
SchedTimingToday,
};
impl Collection {
pub fn timing_today(&mut self) -> Result<SchedTimingToday> {
@ -27,7 +30,7 @@ impl Collection {
now,
self.get_creation_mins_west(),
local_offset,
self.get_rollover(),
self.get_v2_rollover(),
))
}
@ -36,7 +39,20 @@ impl Collection {
SchedulerVersion::V1 => Ok(v1_rollover_from_creation_stamp(
self.storage.creation_stamp()?.0,
)),
SchedulerVersion::V2 => Ok(self.get_rollover().unwrap_or(4)),
SchedulerVersion::V2 => Ok(self.get_v2_rollover().unwrap_or(4)),
}
}
pub(crate) fn set_rollover_for_current_scheduler(&self, hour: u8) -> Result<()> {
match self.sched_ver() {
SchedulerVersion::V1 => {
self.storage
.set_creation_stamp(TimestampSecs(v1_creation_date_adjusted_to_hour(
self.storage.creation_stamp()?.0,
hour,
)))
}
SchedulerVersion::V2 => self.set_v2_rollover(hour as u32),
}
}

View file

@ -294,6 +294,13 @@ impl SqliteStorage {
.map_err(Into::into)
}
pub(crate) fn set_creation_stamp(&self, stamp: TimestampSecs) -> Result<()> {
self.db
.prepare("update col set crt = ?")?
.execute(&[stamp])?;
Ok(())
}
pub(crate) fn set_schema_modified(&self) -> Result<()> {
self.db
.prepare_cached("update col set scm = ?")?