Anki/rslib/src/storage/card/data.rs

179 lines
5.5 KiB
Rust

// 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 rusqlite::types::FromSql;
use rusqlite::types::FromSqlError;
use rusqlite::types::ValueRef;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value;
use crate::card::FsrsMemoryState;
use crate::prelude::*;
use crate::serde::default_on_invalid;
/// Helper for serdeing the card data column.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub(crate) struct CardData {
#[serde(
rename = "pos",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) original_position: Option<u32>,
#[serde(
rename = "s",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) fsrs_stability: Option<f32>,
#[serde(
rename = "d",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) fsrs_difficulty: Option<f32>,
#[serde(
rename = "dr",
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) fsrs_desired_retention: Option<f32>,
#[serde(
skip_serializing_if = "Option::is_none",
deserialize_with = "default_on_invalid"
)]
pub(crate) decay: Option<f32>,
/// A string representation of a JSON object storing optional data
/// associated with the card, so v3 custom scheduling code can persist
/// state.
#[serde(default, rename = "cd", skip_serializing_if = "meta_is_empty")]
pub(crate) custom_data: String,
}
impl CardData {
pub(crate) fn from_card(card: &Card) -> Self {
Self {
original_position: card.original_position,
fsrs_stability: card.memory_state.as_ref().map(|m| m.stability),
fsrs_difficulty: card.memory_state.as_ref().map(|m| m.difficulty),
fsrs_desired_retention: card.desired_retention,
decay: card.decay,
custom_data: card.custom_data.clone(),
}
}
pub(crate) fn from_str(s: &str) -> Self {
serde_json::from_str(s).unwrap_or_default()
}
pub(crate) fn memory_state(&self) -> Option<FsrsMemoryState> {
if let Some(stability) = self.fsrs_stability {
if let Some(difficulty) = self.fsrs_difficulty {
return Some(FsrsMemoryState {
stability,
difficulty,
});
}
}
None
}
pub(crate) fn convert_to_json(&mut self) -> Result<String> {
if let Some(v) = &mut self.fsrs_stability {
round_to_places(v, 4)
}
if let Some(v) = &mut self.fsrs_difficulty {
round_to_places(v, 3)
}
if let Some(v) = &mut self.fsrs_desired_retention {
round_to_places(v, 2)
}
if let Some(v) = &mut self.decay {
round_to_places(v, 3)
}
serde_json::to_string(&self).map_err(Into::into)
}
}
fn round_to_places(value: &mut f32, decimal_places: u32) {
let factor = 10_f32.powi(decimal_places as i32);
*value = (*value * factor).round() / factor;
}
impl FromSql for CardData {
/// Infallible; invalid/missing data results in the default value.
fn column_result(value: ValueRef<'_>) -> std::result::Result<Self, FromSqlError> {
if let ValueRef::Text(s) = value {
Ok(serde_json::from_slice(s).unwrap_or_default())
} else {
Ok(Self::default())
}
}
}
/// Serialize the JSON `data` for a card.
pub(crate) fn card_data_string(card: &Card) -> String {
CardData::from_card(card).convert_to_json().unwrap()
}
fn meta_is_empty(s: &str) -> bool {
matches!(s, "" | "{}")
}
fn validate_custom_data(json_str: &str) -> Result<()> {
if !meta_is_empty(json_str) {
let object: HashMap<&str, Value> =
serde_json::from_str(json_str).or_invalid("custom data not an object")?;
require!(
object.keys().all(|k| k.len() <= 8),
"custom data keys must be <= 8 bytes"
);
require!(
json_str.len() <= 100,
"serialized custom data must be under 100 bytes"
);
}
Ok(())
}
impl Card {
pub(crate) fn validate_custom_data(&self) -> Result<()> {
validate_custom_data(&self.custom_data)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn validation() {
assert!(validate_custom_data("").is_ok());
assert!(validate_custom_data("{}").is_ok());
assert!(validate_custom_data(r#"{"foo": 5}"#).is_ok());
assert!(validate_custom_data(r#"["foo"]"#).is_err());
assert!(validate_custom_data(r#"{"日": 5}"#).is_ok());
assert!(validate_custom_data(r#"{"日本語": 5}"#).is_err());
assert!(validate_custom_data(&format!(r#"{{"foo": "{}"}}"#, "x".repeat(100))).is_err());
}
#[test]
fn compact_floats() {
let mut data = CardData {
original_position: None,
fsrs_stability: Some(123.45678),
fsrs_difficulty: Some(1.234567),
fsrs_desired_retention: Some(0.987654),
decay: Some(0.123456),
custom_data: "".to_string(),
};
assert_eq!(
data.convert_to_json().unwrap(),
r#"{"s":123.4568,"d":1.235,"dr":0.99,"decay":0.123}"#
);
}
}