diff --git a/proto/backend.proto b/proto/backend.proto index 69a0480d9..2fa82a35b 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -50,6 +50,11 @@ message BackendInput { int64 get_card = 38; Card update_card = 39; Card add_card = 40; + int64 get_deck_config = 41; + string add_or_update_deck_config = 42; + Empty all_deck_config = 43; + Empty new_deck_config = 44; + int64 remove_deck_config = 45; } } @@ -83,6 +88,11 @@ message BackendOutput { GetCardOut get_card = 38; Empty update_card = 39; int64 add_card = 40; + string get_deck_config = 41; + int64 add_or_update_deck_config = 42; + string all_deck_config = 43; + string new_deck_config = 44; + Empty remove_deck_config = 45; BackendError error = 2047; } diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index bf1927823..35890984e 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -95,7 +95,6 @@ defaultConf = { class DeckManager: decks: Dict[str, Any] - dconf: Dict[str, Any] # Registry save/load ############################################################# @@ -103,36 +102,27 @@ class DeckManager: def __init__(self, col: anki.storage._Collection) -> None: self.col = col.weakref() self.decks = {} - self.dconf = {} def load(self, decks: str, dconf: str) -> None: self.decks = json.loads(decks) - self.dconf = json.loads(dconf) - # set limits to within bounds - found = False - for c in list(self.dconf.values()): - for t in ("rev", "new"): - pd = "perDay" - if c[t][pd] > 999999: - c[t][pd] = 999999 - self.save(c) - found = True - if not found: - self.changed = False + self.changed = False def save(self, g: Optional[Any] = None) -> None: "Can be called with either a deck or a deck configuration." if g: - g["mod"] = intTime() - g["usn"] = self.col.usn() + # deck conf? + if "maxTaken" in g: + self.updateConf(g) + return + else: + g["mod"] = intTime() + g["usn"] = self.col.usn() self.changed = True def flush(self) -> None: if self.changed: self.col.db.execute( - "update col set decks=?, dconf=?", - json.dumps(self.decks), - json.dumps(self.dconf), + "update col set decks=?", json.dumps(self.decks), ) self.changed = False @@ -368,7 +358,7 @@ class DeckManager: def allConf(self) -> List: "A list of all deck config." - return list(self.dconf.values()) + return list(self.col.backend.all_deck_config().values()) def confForDid(self, did: int) -> Any: deck = self.get(did, default=False) @@ -381,32 +371,25 @@ class DeckManager: return deck def getConf(self, confId: int) -> Any: - return self.dconf[str(confId)] + return self.col.backend.get_deck_config(confId) def updateConf(self, g: Dict[str, Any]) -> None: - self.dconf[str(g["id"])] = g - self.save() + self.col.backend.add_or_update_deck_config(g) def confId(self, name: str, cloneFrom: Optional[Dict[str, Any]] = None) -> int: "Create a new configuration and return id." - if cloneFrom is None: - cloneFrom = defaultConf - c = copy.deepcopy(cloneFrom) - while 1: - id = intTime(1000) - if str(id) not in self.dconf: - break - c["id"] = id - c["name"] = name - self.dconf[str(id)] = c - self.save(c) - return id + if cloneFrom is not None: + conf = copy.deepcopy(cloneFrom) + conf["id"] = 0 + else: + conf = self.col.backend.new_deck_config() + conf["name"] = name + self.updateConf(conf) + return conf["id"] def remConf(self, id) -> None: "Remove a configuration and update all decks using it." - assert int(id) != 1 self.col.modSchema(check=True) - del self.dconf[str(id)] for g in self.all(): # ignore cram decks if "conf" not in g: @@ -414,6 +397,7 @@ class DeckManager: if str(g["conf"]) == str(id): g["conf"] = 1 self.save(g) + self.col.backend.remove_deck_config(id) def setConf(self, grp: Dict[str, Any], id: int) -> None: grp["conf"] = id @@ -428,11 +412,10 @@ class DeckManager: def restoreToDefault(self, conf) -> None: oldOrder = conf["new"]["order"] - new = copy.deepcopy(defaultConf) + new = self.col.backend.new_deck_config() new["id"] = conf["id"] new["name"] = conf["name"] - self.dconf[str(conf["id"])] = new - self.save(new) + self.updateConf(new) # if it was previously randomized, resort if not oldOrder: self.col.sched.resortConf(new) diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index eab65cebe..4bc547217 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -3,6 +3,7 @@ # pylint: skip-file import enum +import json import os from dataclasses import dataclass from typing import ( @@ -43,7 +44,6 @@ try: except: # add compat layer for 32 bit builds that can't use orjson print("reverting to stock json") - import json class orjson: # type: ignore def dumps(obj: Any) -> bytes: @@ -490,6 +490,32 @@ class RustBackend: def add_card(self, card: BackendCard) -> None: card.id = self._run_command(pb.BackendInput(add_card=card)).add_card + def get_deck_config(self, dcid: int) -> Dict[str, Any]: + jstr = self._run_command(pb.BackendInput(get_deck_config=dcid)).get_deck_config + return json.loads(jstr) + + def add_or_update_deck_config(self, conf: Dict[str, Any]) -> None: + conf_json = json.dumps(conf) + id = self._run_command( + pb.BackendInput(add_or_update_deck_config=conf_json) + ).add_or_update_deck_config + conf["id"] = id + + def all_deck_config(self) -> Dict[int, Dict[str, Any]]: + jstr = self._run_command( + pb.BackendInput(all_deck_config=pb.Empty()) + ).all_deck_config + return json.loads(jstr) + + def new_deck_config(self) -> Dict[str, Any]: + jstr = self._run_command( + pb.BackendInput(new_deck_config=pb.Empty()) + ).new_deck_config + return json.loads(jstr) + + def remove_deck_config(self, dcid: int) -> None: + self._run_command(pb.BackendInput(remove_deck_config=dcid)) + def translate_string_in( key: TR, **kwargs: Union[str, int, float] diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0093a062c..94afa4dc9 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -8,6 +8,7 @@ use crate::card::{Card, CardID}; use crate::card::{CardQueue, CardType}; use crate::collection::{open_collection, Collection}; use crate::config::SortKind; +use crate::deckconf::{DeckConf, DeckConfID}; use crate::decks::DeckID; use crate::err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind}; use crate::i18n::{tr_args, FString, I18n}; @@ -68,6 +69,7 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError { AnkiError::Interrupted => V::Interrupted(Empty {}), AnkiError::CollectionNotOpen => V::InvalidInput(pb::Empty {}), AnkiError::CollectionAlreadyOpen => V::InvalidInput(pb::Empty {}), + AnkiError::SchemaChange => V::InvalidInput(pb::Empty {}), }; pb::BackendError { @@ -261,6 +263,16 @@ impl Backend { OValue::UpdateCard(pb::Empty {}) } Value::AddCard(card) => OValue::AddCard(self.add_card(card)?), + Value::GetDeckConfig(dcid) => OValue::GetDeckConfig(self.get_deck_config(dcid)?), + Value::AddOrUpdateDeckConfig(conf_json) => { + OValue::AddOrUpdateDeckConfig(self.add_or_update_deck_config(conf_json)?) + } + Value::AllDeckConfig(_) => OValue::AllDeckConfig(self.all_deck_config()?), + Value::NewDeckConfig(_) => OValue::NewDeckConfig(self.new_deck_config()?), + Value::RemoveDeckConfig(dcid) => { + self.remove_deck_config(dcid)?; + OValue::RemoveDeckConfig(pb::Empty {}) + } }) } @@ -651,6 +663,37 @@ impl Backend { self.with_col(|col| col.transact(None, |ctx| ctx.add_card(&mut card)))?; Ok(card.id.0) } + + fn get_deck_config(&self, dcid: i64) -> Result { + self.with_col(|col| { + let conf = col.get_deck_config(DeckConfID(dcid), true)?.unwrap(); + Ok(serde_json::to_string(&conf)?) + }) + } + + fn add_or_update_deck_config(&self, conf_json: String) -> Result { + let mut conf: DeckConf = serde_json::from_str(&conf_json)?; + self.with_col(|col| { + col.transact(None, |col| { + col.add_or_update_deck_config(&mut conf)?; + Ok(conf.id.0) + }) + }) + } + + fn all_deck_config(&self) -> Result { + self.with_col(|col| { + serde_json::to_string(&col.storage.all_deck_conf()?).map_err(Into::into) + }) + } + + fn new_deck_config(&self) -> Result { + serde_json::to_string(&DeckConf::default()).map_err(Into::into) + } + + fn remove_deck_config(&self, dcid: i64) -> Result<()> { + self.with_col(|col| col.transact(None, |col| col.remove_deck_config(DeckConfID(dcid)))) + } } fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue { diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 6a1533e2e..c9ee0467f 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -95,7 +95,6 @@ impl Undoable for UpdateCardUndo { .storage .get_card(self.0.id)? .ok_or_else(|| AnkiError::invalid_input("card disappeared"))?; - // when called here, update_card should be placing the original content into the redo queue col.update_card(&mut self.0.clone(), ¤t) } } diff --git a/rslib/src/collection.rs b/rslib/src/collection.rs index 66c279dd9..a07c7b763 100644 --- a/rslib/src/collection.rs +++ b/rslib/src/collection.rs @@ -144,4 +144,12 @@ impl Collection { // if we cache this in the future, must make sure to invalidate cache when usn bumped in sync.finish() self.storage.usn(self.server) } + + pub(crate) fn ensure_schema_modified(&self) -> Result<()> { + if !self.storage.schema_modified()? { + Err(AnkiError::SchemaChange) + } else { + Ok(()) + } + } } diff --git a/rslib/src/config.rs b/rslib/src/config.rs index 46c952828..34664c3cb 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -2,19 +2,10 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::decks::DeckID; -use serde::Deserialize as DeTrait; +use crate::serde::default_on_invalid; use serde_aux::field_attributes::deserialize_number_from_string; use serde_derive::Deserialize; -use serde_json::Value; -pub(crate) fn default_on_invalid<'de, T, D>(deserializer: D) -> Result -where - T: Default + DeTrait<'de>, - D: serde::de::Deserializer<'de>, -{ - let v: Value = DeTrait::deserialize(deserializer)?; - Ok(T::deserialize(v).unwrap_or_default()) -} #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct Config { diff --git a/rslib/src/deckconf.rs b/rslib/src/deckconf.rs new file mode 100644 index 000000000..383682b40 --- /dev/null +++ b/rslib/src/deckconf.rs @@ -0,0 +1,243 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::{ + collection::Collection, + define_newtype, + err::{AnkiError, Result}, + serde::default_on_invalid, + timestamp::{TimestampMillis, TimestampSecs}, + types::Usn, +}; +use serde_aux::field_attributes::{deserialize_bool_from_anything, deserialize_number_from_string}; +use serde_derive::{Deserialize, Serialize}; +use serde_json::Value; +use serde_repr::{Deserialize_repr, Serialize_repr}; +use serde_tuple::Serialize_tuple; +use std::collections::HashMap; + +define_newtype!(DeckConfID, i64); + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DeckConf { + #[serde(deserialize_with = "deserialize_number_from_string")] + pub(crate) id: DeckConfID, + #[serde(rename = "mod", deserialize_with = "deserialize_number_from_string")] + pub(crate) mtime: TimestampSecs, + pub(crate) name: String, + pub(crate) usn: Usn, + max_taken: i32, + autoplay: bool, + #[serde(deserialize_with = "deserialize_bool_from_anything")] + timer: bool, + #[serde(default)] + replayq: bool, + pub(crate) new: NewConf, + pub(crate) rev: RevConf, + pub(crate) lapse: LapseConf, + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NewConf { + #[serde(default)] + bury: bool, + #[serde(deserialize_with = "default_on_invalid")] + delays: Vec, + initial_factor: u16, + #[serde(deserialize_with = "default_on_invalid")] + ints: NewCardIntervals, + #[serde(deserialize_with = "default_on_invalid")] + order: NewCardOrder, + #[serde(deserialize_with = "default_on_invalid")] + pub(crate) per_day: u32, + + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Clone)] +pub struct NewCardIntervals { + good: u16, + easy: u16, + _unused: u16, +} + +impl Default for NewCardIntervals { + fn default() -> Self { + Self { + good: 1, + easy: 4, + _unused: 7, + } + } +} + +#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone)] +#[repr(u8)] +pub enum NewCardOrder { + Random = 0, + Due = 1, +} + +impl Default for NewCardOrder { + fn default() -> Self { + Self::Due + } +} + +fn hard_factor_default() -> f32 { + 1.2 +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct RevConf { + #[serde(default)] + bury: bool, + ease4: f32, + ivl_fct: f32, + max_ivl: u32, + #[serde(deserialize_with = "default_on_invalid")] + pub(crate) per_day: u32, + #[serde(default = "hard_factor_default")] + hard_factor: f32, + + #[serde(flatten)] + other: HashMap, +} + +#[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, Clone)] +#[repr(u8)] +pub enum LeechAction { + Suspend = 0, + TagOnly = 1, +} + +#[derive(Serialize, Deserialize, Debug, PartialEq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct LapseConf { + #[serde(deserialize_with = "default_on_invalid")] + delays: Vec, + #[serde(deserialize_with = "default_on_invalid")] + leech_action: LeechAction, + leech_fails: u32, + min_int: u32, + mult: f32, + + #[serde(flatten)] + other: HashMap, +} + +impl Default for LeechAction { + fn default() -> Self { + LeechAction::Suspend + } +} + +impl Default for RevConf { + fn default() -> Self { + RevConf { + bury: false, + ease4: 1.3, + ivl_fct: 1.0, + max_ivl: 36500, + per_day: 200, + hard_factor: 1.2, + other: Default::default(), + } + } +} + +impl Default for NewConf { + fn default() -> Self { + NewConf { + bury: false, + delays: vec![1.0, 10.0], + initial_factor: 2500, + ints: NewCardIntervals::default(), + order: NewCardOrder::default(), + per_day: 20, + other: Default::default(), + } + } +} + +impl Default for LapseConf { + fn default() -> Self { + LapseConf { + delays: vec![10.0], + leech_action: LeechAction::default(), + leech_fails: 8, + min_int: 1, + mult: 0.0, + other: Default::default(), + } + } +} + +impl Default for DeckConf { + fn default() -> Self { + DeckConf { + id: DeckConfID(0), + mtime: TimestampSecs(0), + name: "Default".to_string(), + usn: Usn(0), + max_taken: 60, + autoplay: true, + timer: false, + replayq: true, + new: Default::default(), + rev: Default::default(), + lapse: Default::default(), + other: Default::default(), + } + } +} + +impl Collection { + pub fn get_deck_config(&self, dcid: DeckConfID, fallback: bool) -> Result> { + let conf = self.storage.all_deck_conf()?; + if let Some(conf) = conf.get(&dcid) { + return Ok(Some(conf.clone())); + } + if fallback { + if let Some(conf) = conf.get(&DeckConfID(1)) { + return Ok(Some(conf.clone())); + } + // if even the default deck config is missing, just return the defaults + return Ok(Some(DeckConf::default())); + } + Ok(None) + } + + pub(crate) fn add_or_update_deck_config(&self, conf: &mut DeckConf) -> Result<()> { + let mut allconf = self.storage.all_deck_conf()?; + if conf.id.0 == 0 { + conf.id.0 = TimestampMillis::now().0; + loop { + if !allconf.contains_key(&conf.id) { + break; + } + conf.id.0 += 1; + } + } + conf.mtime = TimestampSecs::now(); + conf.usn = self.usn()?; + allconf.insert(conf.id, conf.clone()); + self.storage.flush_deck_conf(&allconf) + } + + pub(crate) fn remove_deck_config(&self, dcid: DeckConfID) -> Result<()> { + if dcid.0 == 1 { + return Err(AnkiError::invalid_input("can't delete default conf")); + } + self.ensure_schema_modified()?; + let mut allconf = self.storage.all_deck_conf()?; + allconf.remove(&dcid); + self.storage.flush_deck_conf(&allconf) + } +} diff --git a/rslib/src/decks.rs b/rslib/src/decks.rs index 2511d5af1..1f1524418 100644 --- a/rslib/src/decks.rs +++ b/rslib/src/decks.rs @@ -6,7 +6,6 @@ use serde_aux::field_attributes::deserialize_number_from_string; use serde_derive::Deserialize; define_newtype!(DeckID, i64); -define_newtype!(DeckConfID, i64); #[derive(Deserialize)] pub struct Deck { diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 8208370d5..b1f2ab894 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -39,6 +39,9 @@ pub enum AnkiError { #[fail(display = "Close the existing collection first.")] CollectionAlreadyOpen, + + #[fail(display = "Operation modifies schema, but schema not marked modified.")] + SchemaChange, } // error helpers diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index aa2de288b..41056c4a2 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -14,6 +14,7 @@ pub mod card; pub mod cloze; pub mod collection; pub mod config; +pub mod deckconf; pub mod decks; pub mod err; pub mod i18n; @@ -24,6 +25,7 @@ pub mod notes; pub mod notetypes; pub mod sched; pub mod search; +pub mod serde; pub mod storage; pub mod template; pub mod template_filters; diff --git a/rslib/src/serde.rs b/rslib/src/serde.rs new file mode 100644 index 000000000..280302ae6 --- /dev/null +++ b/rslib/src/serde.rs @@ -0,0 +1,14 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use serde::Deserialize as DeTrait; +use serde_json::Value; + +pub(crate) fn default_on_invalid<'de, T, D>(deserializer: D) -> Result +where + T: Default + DeTrait<'de>, + D: serde::de::Deserializer<'de>, +{ + let v: Value = DeTrait::deserialize(deserializer)?; + Ok(T::deserialize(v).unwrap_or_default()) +} diff --git a/rslib/src/storage/deckconf.rs b/rslib/src/storage/deckconf.rs new file mode 100644 index 000000000..dbe2a18b3 --- /dev/null +++ b/rslib/src/storage/deckconf.rs @@ -0,0 +1,29 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use super::SqliteStorage; +use crate::{ + deckconf::{DeckConf, DeckConfID}, + err::{AnkiError, Result}, +}; +use rusqlite::{params, NO_PARAMS}; +use std::collections::HashMap; + +impl SqliteStorage { + pub(crate) fn all_deck_conf(&self) -> Result> { + self.db + .prepare_cached("select dconf from col")? + .query_and_then(NO_PARAMS, |row| -> Result<_> { + Ok(serde_json::from_str(row.get_raw(0).as_str()?)?) + })? + .next() + .ok_or_else(|| AnkiError::invalid_input("no col table"))? + } + + pub(crate) fn flush_deck_conf(&self, conf: &HashMap) -> Result<()> { + self.db + .prepare_cached("update col set dconf = ?")? + .execute(params![&serde_json::to_string(conf)?])?; + Ok(()) + } +} diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index e66290ed5..b4549b14e 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -1,4 +1,5 @@ mod card; +mod deckconf; mod sqlite; pub(crate) use sqlite::SqliteStorage; diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 19ed14262..b784e819f 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -304,4 +304,11 @@ impl SqliteStorage { conf.rollover, )) } + + pub(crate) fn schema_modified(&self) -> Result { + self.db + .prepare_cached("select scm > ls from col")? + .query_row(NO_PARAMS, |row| row.get(0)) + .map_err(Into::into) + } }