diff --git a/proto/backend.proto b/proto/backend.proto index 66bcebf25..ad16f6bed 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -47,6 +47,8 @@ message BackendInput { Empty restore_trash = 35; OpenCollectionIn open_collection = 36; Empty close_collection = 37; + int64 get_card = 38; + Card update_card = 39; } } @@ -77,6 +79,8 @@ message BackendOutput { Empty restore_trash = 35; Empty open_collection = 36; Empty close_collection = 37; + GetCardOut get_card = 38; + Empty update_card = 39; BackendError error = 2047; } @@ -367,3 +371,28 @@ enum BuiltinSortKind { CARD_DECK = 11; CARD_TEMPLATE = 12; } + +message GetCardOut { + Card card = 1; +} + +message Card { + int64 id = 1; + int64 nid = 2; + int64 did = 3; + uint32 ord = 4; + int64 mtime = 5; + sint32 usn = 6; + uint32 ctype = 7; + sint32 queue = 8; + sint32 due = 9; + uint32 ivl = 10; + uint32 factor = 11; + uint32 reps = 12; + uint32 lapses = 13; + uint32 left = 14; + sint32 odue = 15; + int64 odid = 16; + uint32 flags = 17; + string data = 18; +} diff --git a/pylib/anki/cards.py b/pylib/anki/cards.py index f8ac57314..d3887410c 100644 --- a/pylib/anki/cards.py +++ b/pylib/anki/cards.py @@ -12,6 +12,7 @@ from anki import hooks from anki.consts import * from anki.models import NoteType, Template from anki.notes import Note +from anki.rsbackend import BackendCard from anki.sound import AVTag from anki.utils import intTime, joinFields, timestampID @@ -33,9 +34,7 @@ class Card: lastIvl: int ord: int - def __init__( - self, col: anki.collection._Collection, id: Optional[int] = None - ) -> None: + def __init__(self, col: anki.storage._Collection, id: Optional[int] = None) -> None: self.col = col.weakref() self.timerStarted = None self._render_output: Optional[anki.template.TemplateRenderOutput] = None @@ -61,68 +60,59 @@ class Card: self.data = "" def load(self) -> None: - ( - self.id, - self.nid, - self.did, - self.ord, - self.mod, - self.usn, - self.type, - self.queue, - self.due, - self.ivl, - self.factor, - self.reps, - self.lapses, - self.left, - self.odue, - self.odid, - self.flags, - self.data, - ) = self.col.db.first("select * from cards where id = ?", self.id) self._render_output = None self._note = None + c = self.col.backend.get_card(self.id) + assert c + self.nid = c.nid + self.did = c.did + self.ord = c.ord + self.mod = c.mtime + self.usn = c.usn + self.type = c.ctype + self.queue = c.queue + self.due = c.due + self.ivl = c.ivl + self.factor = c.factor + self.reps = c.reps + self.lapses = c.lapses + self.left = c.left + self.odue = c.odue + self.odid = c.odid + self.flags = c.flags + self.data = c.data - def _preFlush(self) -> None: - hooks.card_will_flush(self) - self.mod = intTime() - self.usn = self.col.usn() - # bug check + def _bugcheck(self) -> None: if ( self.queue == QUEUE_TYPE_REV and self.odue and not self.col.decks.isDyn(self.did) ): hooks.card_odue_was_invalid() - assert self.due < 4294967296 def flush(self) -> None: - self._preFlush() - self.col.db.execute( - """ -insert or replace into cards values -(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", - self.id, - self.nid, - self.did, - self.ord, - self.mod, - self.usn, - self.type, - self.queue, - self.due, - self.ivl, - self.factor, - self.reps, - self.lapses, - self.left, - self.odue, - self.odid, - self.flags, - self.data, + self._bugcheck() + hooks.card_will_flush(self) + # mtime & usn are set by backend + card = BackendCard( + id=self.id, + nid=self.nid, + did=self.did, + ord=self.ord, + ctype=self.type, + queue=self.queue, + due=self.due, + ivl=self.ivl, + factor=self.factor, + reps=self.reps, + lapses=self.lapses, + left=self.left, + odue=self.odue, + odid=self.odid, + flags=self.flags, + data=self.data, ) - self.col.log(self) + self.col.backend.update_card(card) def question(self, reload: bool = False, browser: bool = False) -> str: return self.css() + self.render_output(reload, browser).question_text diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index fedd21f76..026ccb886 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -956,7 +956,7 @@ and type=0""", ) rowcount = self.db.scalar("select changes()") if rowcount: - problems.append( + syncable_problems.append( "Found %d new cards with a due number >= 1,000,000 - consider repositioning them in the Browse screen." % rowcount ) diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 630d74368..8a98b9be4 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -36,6 +36,7 @@ assert ankirspy.buildhash() == anki.buildinfo.buildhash SchedTimingToday = pb.SchedTimingTodayOut BuiltinSortKind = pb.BuiltinSortKind +BackendCard = pb.Card try: import orjson @@ -480,6 +481,12 @@ class RustBackend: pb.BackendInput(search_notes=pb.SearchNotesIn(search=search)) ).search_notes.note_ids + def get_card(self, cid: int) -> Optional[pb.Card]: + return self._run_command(pb.BackendInput(get_card=cid)).get_card.card + + def update_card(self, card: BackendCard) -> None: + self._run_command(pb.BackendInput(update_card=card)) + def translate_string_in( key: TR, **kwargs: Union[str, int, float] diff --git a/pylib/anki/sched.py b/pylib/anki/sched.py index 0c59773bc..0e9c07967 100644 --- a/pylib/anki/sched.py +++ b/pylib/anki/sched.py @@ -341,7 +341,7 @@ limit %d""" resched = self._resched(card) if "mult" in conf and resched: # review that's lapsed - card.ivl = max(1, conf["minInt"], card.ivl * conf["mult"]) + card.ivl = max(1, conf["minInt"], int(card.ivl * conf["mult"])) else: # new card; no ivl adjustment pass diff --git a/pylib/anki/stats.py b/pylib/anki/stats.py index 69ab0c415..59ed68ea8 100644 --- a/pylib/anki/stats.py +++ b/pylib/anki/stats.py @@ -40,14 +40,15 @@ class CardStats: self.addLine(_("First Review"), self.date(first / 1000)) self.addLine(_("Latest Review"), self.date(last / 1000)) if c.type in (CARD_TYPE_LRN, CARD_TYPE_REV): + next: Optional[str] = None if c.odid or c.queue < QUEUE_TYPE_NEW: - next = None + pass else: if c.queue in (QUEUE_TYPE_REV, QUEUE_TYPE_DAY_LEARN_RELEARN): - next = time.time() + ((c.due - self.col.sched.today) * 86400) + n = time.time() + ((c.due - self.col.sched.today) * 86400) else: - next = c.due - next = self.date(next) + n = c.due + next = self.date(n) if next: self.addLine(self.col.tr(TR.STATISTICS_DUE_DATE), next) if c.queue == QUEUE_TYPE_REV: diff --git a/pylib/tests/test_models.py b/pylib/tests/test_models.py index 8f6ceecee..ebcc3d0fa 100644 --- a/pylib/tests/test_models.py +++ b/pylib/tests/test_models.py @@ -318,7 +318,7 @@ def test_modelChange(): try: c1.load() assert 0 - except TypeError: + except AssertionError: pass # but we have two cards, as a new one was generated assert len(f.cards()) == 2 diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 1feb94f4c..cd98b1968 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -968,7 +968,7 @@ def test_timing(): c2 = d.sched.getCard() assert c2.queue == QUEUE_TYPE_REV # if the failed card becomes due, it should show first - c.due = time.time() - 1 + c.due = intTime() - 1 c.flush() d.reset() c = d.sched.getCard() diff --git a/qt/aqt/main.py b/qt/aqt/main.py index 7e84f59aa..3f605db6f 100644 --- a/qt/aqt/main.py +++ b/qt/aqt/main.py @@ -442,7 +442,7 @@ close the profile or restart Anki.""" def loadCollection(self) -> bool: try: - return self._loadCollection() + self._loadCollection() except Exception as e: showWarning( tr(TR.ERRORS_UNABLE_OPEN_COLLECTION) + "\n" + traceback.format_exc() @@ -460,15 +460,22 @@ close the profile or restart Anki.""" self.showProfileManager() return False - def _loadCollection(self) -> bool: + # make sure we don't get into an inconsistent state if an add-on + # has broken the deck browser or the did_load hook + try: + self.maybeEnableUndo() + gui_hooks.collection_did_load(self.col) + self.moveToState("deckBrowser") + except Exception as e: + # dump error to stderr so it gets picked up by errors.py + traceback.print_exc() + + return True + + def _loadCollection(self): cpath = self.pm.collectionPath() self.col = Collection(cpath, backend=self.backend) - self.setEnabled(True) - self.maybeEnableUndo() - gui_hooks.collection_did_load(self.col) - self.moveToState("deckBrowser") - return True def reopen(self): cpath = self.pm.collectionPath() @@ -1241,7 +1248,10 @@ and if the problem comes up again, please ask on the support site.""" ########################################################################## def onSchemaMod(self, arg): - return askUser( + progress_shown = self.progress.busy() + if progress_shown: + self.progress.finish() + ret = askUser( _( """\ The requested change will require a full upload of the database when \ @@ -1250,6 +1260,9 @@ waiting on another device that haven't been synchronized here yet, they \ will be lost. Continue?""" ) ) + if progress_shown: + self.progress.start() + return ret # Advanced features ########################################################################## diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 0240d8e9d..81a71c266 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -4,8 +4,11 @@ use crate::backend::dbproxy::db_command_bytes; use crate::backend_proto::backend_input::Value; use crate::backend_proto::{BuiltinSortKind, Empty, RenderedTemplateReplacement, SyncMediaIn}; +use crate::card::{Card, CardID}; +use crate::card::{CardQueue, CardType}; use crate::collection::{open_collection, Collection}; use crate::config::SortKind; +use crate::decks::DeckID; use crate::err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind}; use crate::i18n::{tr_args, FString, I18n}; use crate::latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex}; @@ -13,6 +16,7 @@ use crate::log::{default_logger, Logger}; use crate::media::check::MediaChecker; use crate::media::sync::MediaSyncProgress; use crate::media::MediaManager; +use crate::notes::NoteID; use crate::sched::cutoff::{local_minutes_west_for_stamp, sched_timing_today}; use crate::sched::timespan::{answer_button_time, learning_congrats, studied_today, time_span}; use crate::search::{search_cards, search_notes, SortMode}; @@ -21,10 +25,13 @@ use crate::template::{ RenderedNode, }; use crate::text::{extract_av_tags, strip_av_tags, AVTag}; +use crate::timestamp::TimestampSecs; +use crate::types::Usn; use crate::{backend_proto as pb, log}; use fluent::FluentValue; use prost::Message; use std::collections::{HashMap, HashSet}; +use std::convert::TryFrom; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::runtime::Runtime; @@ -248,6 +255,11 @@ impl Backend { } Value::SearchCards(input) => OValue::SearchCards(self.search_cards(input)?), Value::SearchNotes(input) => OValue::SearchNotes(self.search_notes(input)?), + Value::GetCard(cid) => OValue::GetCard(self.get_card(cid)?), + Value::UpdateCard(card) => { + self.update_card(card)?; + OValue::UpdateCard(pb::Empty {}) + } }) } @@ -599,7 +611,9 @@ impl Backend { SortMode::FromConfig }; let cids = search_cards(ctx, &input.search, order)?; - Ok(pb::SearchCardsOut { card_ids: cids }) + Ok(pb::SearchCardsOut { + card_ids: cids.into_iter().map(|v| v.0).collect(), + }) }) }) } @@ -608,10 +622,24 @@ impl Backend { self.with_col(|col| { col.with_ctx(|ctx| { let nids = search_notes(ctx, &input.search)?; - Ok(pb::SearchNotesOut { note_ids: nids }) + Ok(pb::SearchNotesOut { + note_ids: nids.into_iter().map(|v| v.0).collect(), + }) }) }) } + + fn get_card(&self, cid: i64) -> Result { + let card = self.with_col(|col| col.with_ctx(|ctx| ctx.storage.get_card(CardID(cid))))?; + Ok(pb::GetCardOut { + card: card.map(card_to_pb), + }) + } + + fn update_card(&self, pbcard: pb::Card) -> Result<()> { + let mut card = pbcard_to_native(pbcard)?; + self.with_col(|col| col.with_ctx(|ctx| ctx.update_card(&mut card))) + } } fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue { @@ -704,3 +732,53 @@ fn sort_kind_from_pb(kind: i32) -> SortKind { _ => SortKind::NoteCreation, } } + +fn card_to_pb(c: Card) -> pb::Card { + pb::Card { + id: c.id.0, + nid: c.nid.0, + did: c.did.0, + ord: c.ord as u32, + mtime: c.mtime.0, + usn: c.usn.0, + ctype: c.ctype as u32, + queue: c.queue as i32, + due: c.due, + ivl: c.ivl, + factor: c.factor as u32, + reps: c.reps, + lapses: c.lapses, + left: c.left, + odue: c.odue, + odid: c.odid.0, + flags: c.flags as u32, + data: c.data, + } +} + +fn pbcard_to_native(c: pb::Card) -> Result { + let ctype = CardType::try_from(c.ctype as u8) + .map_err(|_| AnkiError::invalid_input("invalid card type"))?; + let queue = CardQueue::try_from(c.queue as i8) + .map_err(|_| AnkiError::invalid_input("invalid card queue"))?; + Ok(Card { + id: CardID(c.id), + nid: NoteID(c.nid), + did: DeckID(c.did), + ord: c.ord as u16, + mtime: TimestampSecs(c.mtime), + usn: Usn(c.usn), + ctype, + queue, + due: c.due, + ivl: c.ivl, + factor: c.factor as u16, + reps: c.reps, + lapses: c.lapses, + left: c.left, + odue: c.odue, + odid: DeckID(c.odid), + flags: c.flags as u8, + data: c.data, + }) +} diff --git a/rslib/src/card.rs b/rslib/src/card.rs index 1ee1386b0..05bd3d857 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -1,9 +1,16 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use crate::decks::DeckID; +use crate::define_newtype; +use crate::err::Result; +use crate::notes::NoteID; +use crate::{collection::RequestContext, timestamp::TimestampSecs, types::Usn}; use num_enum::TryFromPrimitive; use serde_repr::{Deserialize_repr, Serialize_repr}; +define_newtype!(CardID, i64); + #[derive(Serialize_repr, Deserialize_repr, Debug, PartialEq, TryFromPrimitive, Clone, Copy)] #[repr(u8)] pub enum CardType { @@ -31,3 +38,58 @@ pub enum CardQueue { UserBuried = -2, SchedBuried = -3, } + +#[derive(Debug, Clone)] +pub struct Card { + pub(crate) id: CardID, + pub(crate) nid: NoteID, + pub(crate) did: DeckID, + pub(crate) ord: u16, + pub(crate) mtime: TimestampSecs, + pub(crate) usn: Usn, + pub(crate) ctype: CardType, + pub(crate) queue: CardQueue, + pub(crate) due: i32, + pub(crate) ivl: u32, + pub(crate) factor: u16, + pub(crate) reps: u32, + pub(crate) lapses: u32, + pub(crate) left: u32, + pub(crate) odue: i32, + pub(crate) odid: DeckID, + pub(crate) flags: u8, + pub(crate) data: String, +} + +impl Default for Card { + fn default() -> Self { + Self { + id: CardID(0), + nid: NoteID(0), + did: DeckID(0), + ord: 0, + mtime: TimestampSecs(0), + usn: Usn(0), + ctype: CardType::New, + queue: CardQueue::New, + due: 0, + ivl: 0, + factor: 0, + reps: 0, + lapses: 0, + left: 0, + odue: 0, + odid: DeckID(0), + flags: 0, + data: "".to_string(), + } + } +} + +impl RequestContext<'_> { + pub fn update_card(&mut self, card: &mut Card) -> Result<()> { + card.mtime = TimestampSecs::now(); + card.usn = self.storage.usn()?; + self.storage.update_card(card) + } +} diff --git a/rslib/src/config.rs b/rslib/src/config.rs index c768b1122..46c952828 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -1,7 +1,7 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::types::ObjID; +use crate::decks::DeckID; use serde::Deserialize as DeTrait; use serde_aux::field_attributes::deserialize_number_from_string; use serde_derive::Deserialize; @@ -22,7 +22,7 @@ pub struct Config { rename = "curDeck", deserialize_with = "deserialize_number_from_string" )] - pub(crate) current_deck_id: ObjID, + pub(crate) current_deck_id: DeckID, pub(crate) rollover: Option, pub(crate) creation_offset: Option, pub(crate) local_offset: Option, diff --git a/rslib/src/decks.rs b/rslib/src/decks.rs index 16b80b261..2511d5af1 100644 --- a/rslib/src/decks.rs +++ b/rslib/src/decks.rs @@ -1,18 +1,21 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::types::ObjID; +use crate::define_newtype; 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 { #[serde(deserialize_with = "deserialize_number_from_string")] - pub(crate) id: ObjID, + pub(crate) id: DeckID, pub(crate) name: String, } -pub(crate) fn child_ids<'a>(decks: &'a [Deck], name: &str) -> impl Iterator + 'a { +pub(crate) fn child_ids<'a>(decks: &'a [Deck], name: &str) -> impl Iterator + 'a { let prefix = format!("{}::", name.to_ascii_lowercase()); decks .iter() @@ -20,7 +23,7 @@ pub(crate) fn child_ids<'a>(decks: &'a [Deck], name: &str) -> impl Iterator Option<&Deck> { +pub(crate) fn get_deck(decks: &[Deck], id: DeckID) -> Option<&Deck> { for d in decks { if d.id == id { return Some(d); diff --git a/rslib/src/lib.rs b/rslib/src/lib.rs index da4d91107..87253f998 100644 --- a/rslib/src/lib.rs +++ b/rslib/src/lib.rs @@ -28,5 +28,5 @@ pub mod storage; pub mod template; pub mod template_filters; pub mod text; -pub mod time; +pub mod timestamp; pub mod types; diff --git a/rslib/src/media/check.rs b/rslib/src/media/check.rs index 971747b50..dd1c4bde3 100644 --- a/rslib/src/media/check.rs +++ b/rslib/src/media/check.rs @@ -390,7 +390,7 @@ where self.maybe_fire_progress_cb()?; } let nt = note_types - .get(¬e.mid) + .get(¬e.ntid) .ok_or_else(|| AnkiError::DBError { info: "missing note type".to_string(), kind: DBErrorKind::MissingEntity, diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index cad1f614c..74f69200f 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -4,20 +4,20 @@ /// At the moment, this is just basic note reading/updating functionality for /// the media DB check. use crate::err::{AnkiError, DBErrorKind, Result}; +use crate::notetypes::NoteTypeID; use crate::text::strip_html_preserving_image_filenames; -use crate::time::i64_unix_secs; -use crate::{ - notetypes::NoteType, - types::{ObjID, Timestamp, Usn}, -}; +use crate::timestamp::TimestampSecs; +use crate::{define_newtype, notetypes::NoteType, types::Usn}; use rusqlite::{params, Connection, Row, NO_PARAMS}; use std::convert::TryInto; +define_newtype!(NoteID, i64); + #[derive(Debug)] pub(super) struct Note { - pub id: ObjID, - pub mid: ObjID, - pub mtime_secs: Timestamp, + pub id: NoteID, + pub ntid: NoteTypeID, + pub mtime: TimestampSecs, pub usn: Usn, fields: Vec, } @@ -48,7 +48,7 @@ pub(crate) fn field_checksum(text: &str) -> u32 { } #[allow(dead_code)] -fn get_note(db: &Connection, nid: ObjID) -> Result> { +fn get_note(db: &Connection, nid: NoteID) -> Result> { let mut stmt = db.prepare_cached("select id, mid, mod, usn, flds from notes where id=?")?; let note = stmt.query_and_then(params![nid], row_to_note)?.next(); @@ -72,8 +72,8 @@ pub(super) fn for_every_note Result<()>>( fn row_to_note(row: &Row) -> Result { Ok(Note { id: row.get(0)?, - mid: row.get(1)?, - mtime_secs: row.get(2)?, + ntid: row.get(1)?, + mtime: row.get(2)?, usn: row.get(3)?, fields: row .get_raw(4) @@ -85,9 +85,9 @@ fn row_to_note(row: &Row) -> Result { } pub(super) fn set_note(db: &Connection, note: &mut Note, note_type: &NoteType) -> Result<()> { - note.mtime_secs = i64_unix_secs(); + note.mtime = TimestampSecs::now(); // hard-coded for now - note.usn = -1; + note.usn = Usn(-1); let field1_nohtml = strip_html_preserving_image_filenames(¬e.fields()[0]); let csum = field_checksum(field1_nohtml.as_ref()); let sort_field = if note_type.sort_field_idx == 0 { @@ -106,7 +106,7 @@ pub(super) fn set_note(db: &Connection, note: &mut Note, note_type: &NoteType) - let mut stmt = db.prepare_cached("update notes set mod=?,usn=?,flds=?,sfld=?,csum=? where id=?")?; stmt.execute(params![ - note.mtime_secs, + note.mtime, note.usn, note.fields().join("\x1f"), sort_field, diff --git a/rslib/src/notetypes.rs b/rslib/src/notetypes.rs index 4b9295939..9324c8be3 100644 --- a/rslib/src/notetypes.rs +++ b/rslib/src/notetypes.rs @@ -1,14 +1,16 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -use crate::types::ObjID; +use crate::define_newtype; use serde_aux::field_attributes::deserialize_number_from_string; use serde_derive::Deserialize; +define_newtype!(NoteTypeID, i64); + #[derive(Deserialize, Debug)] pub(crate) struct NoteType { #[serde(deserialize_with = "deserialize_number_from_string")] - pub id: ObjID, + pub id: NoteTypeID, pub name: String, #[serde(rename = "sortf")] pub sort_field_idx: u16, diff --git a/rslib/src/search/cards.rs b/rslib/src/search/cards.rs index 8ab045b7b..db9c8effa 100644 --- a/rslib/src/search/cards.rs +++ b/rslib/src/search/cards.rs @@ -2,12 +2,12 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::{parser::Node, sqlwriter::node_to_sql}; +use crate::card::CardID; use crate::card::CardType; use crate::collection::RequestContext; use crate::config::SortKind; use crate::err::Result; use crate::search::parser::parse; -use crate::types::ObjID; use rusqlite::params; pub(crate) enum SortMode { @@ -21,7 +21,7 @@ pub(crate) fn search_cards<'a, 'b>( req: &'a mut RequestContext<'b>, search: &'a str, order: SortMode, -) -> Result> { +) -> Result> { let top_node = Node::Group(parse(search)?); let (sql, args) = node_to_sql(req, &top_node)?; @@ -50,7 +50,7 @@ pub(crate) fn search_cards<'a, 'b>( } let mut stmt = req.storage.db.prepare(&sql)?; - let ids: Vec = stmt + let ids: Vec<_> = stmt .query_map(&args, |row| row.get(0))? .collect::>()?; diff --git a/rslib/src/search/notes.rs b/rslib/src/search/notes.rs index 50021a92e..5af735809 100644 --- a/rslib/src/search/notes.rs +++ b/rslib/src/search/notes.rs @@ -4,13 +4,13 @@ use super::{parser::Node, sqlwriter::node_to_sql}; use crate::collection::RequestContext; use crate::err::Result; +use crate::notes::NoteID; use crate::search::parser::parse; -use crate::types::ObjID; pub(crate) fn search_notes<'a, 'b>( req: &'a mut RequestContext<'b>, search: &'a str, -) -> Result> { +) -> Result> { let top_node = Node::Group(parse(search)?); let (sql, args) = node_to_sql(req, &top_node)?; @@ -20,7 +20,7 @@ pub(crate) fn search_notes<'a, 'b>( ); let mut stmt = req.storage.db.prepare(&sql)?; - let ids: Vec = stmt + let ids: Vec<_> = stmt .query_map(&args, |row| row.get(0))? .collect::>()?; diff --git a/rslib/src/search/parser.rs b/rslib/src/search/parser.rs index 4018cc56c..a42370152 100644 --- a/rslib/src/search/parser.rs +++ b/rslib/src/search/parser.rs @@ -2,7 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use crate::err::{AnkiError, Result}; -use crate::types::ObjID; +use crate::notetypes::NoteTypeID; use nom::branch::alt; use nom::bytes::complete::{escaped, is_not, tag, take_while1}; use nom::character::complete::{anychar, char, one_of}; @@ -58,7 +58,7 @@ pub(super) enum SearchNode<'a> { AddedInDays(u32), CardTemplate(TemplateKind), Deck(Cow<'a, str>), - NoteTypeID(ObjID), + NoteTypeID(NoteTypeID), NoteType(Cow<'a, str>), Rated { days: u32, @@ -66,7 +66,7 @@ pub(super) enum SearchNode<'a> { }, Tag(Cow<'a, str>), Duplicates { - note_type_id: ObjID, + note_type_id: NoteTypeID, text: String, }, State(StateKind), @@ -339,7 +339,7 @@ fn parse_rated(val: &str) -> ParseResult> { /// eg dupes:1231,hello fn parse_dupes(val: &str) -> ParseResult> { let mut it = val.splitn(2, ','); - let mid: ObjID = it.next().unwrap().parse()?; + let mid: NoteTypeID = it.next().unwrap().parse()?; let text = it.next().ok_or(ParseError {})?; Ok(SearchNode::Duplicates { note_type_id: mid, diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 1f5e7ef0c..f1348ba58 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -7,11 +7,10 @@ use crate::decks::child_ids; use crate::decks::get_deck; use crate::err::{AnkiError, Result}; use crate::notes::field_checksum; +use crate::notetypes::NoteTypeID; use crate::text::matches_wildcard; use crate::text::without_combining; -use crate::{ - collection::RequestContext, text::strip_html_preserving_image_filenames, types::ObjID, -}; +use crate::{collection::RequestContext, text::strip_html_preserving_image_filenames}; use std::fmt::Write; struct SqlWriter<'a, 'b> { @@ -342,7 +341,7 @@ impl SqlWriter<'_, '_> { Ok(()) } - fn write_dupes(&mut self, ntid: ObjID, text: &str) { + fn write_dupes(&mut self, ntid: NoteTypeID, text: &str) { let text_nohtml = strip_html_preserving_image_filenames(text); let csum = field_checksum(text_nohtml.as_ref()); write!( diff --git a/rslib/src/storage/card.rs b/rslib/src/storage/card.rs new file mode 100644 index 000000000..e7a998121 --- /dev/null +++ b/rslib/src/storage/card.rs @@ -0,0 +1,116 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +use crate::cached_sql; +use crate::card::{Card, CardID, CardQueue, CardType}; +use crate::err::{AnkiError, Result}; +use rusqlite::params; +use rusqlite::{ + types::{FromSql, FromSqlError, ValueRef}, + OptionalExtension, +}; +use std::convert::TryFrom; + +impl FromSql for CardType { + fn column_result(value: ValueRef<'_>) -> std::result::Result { + if let ValueRef::Integer(i) = value { + Ok(Self::try_from(i as u8).map_err(|_| FromSqlError::InvalidType)?) + } else { + Err(FromSqlError::InvalidType) + } + } +} + +impl FromSql for CardQueue { + fn column_result(value: ValueRef<'_>) -> std::result::Result { + if let ValueRef::Integer(i) = value { + Ok(Self::try_from(i as i8).map_err(|_| FromSqlError::InvalidType)?) + } else { + Err(FromSqlError::InvalidType) + } + } +} + +impl super::StorageContext<'_> { + pub fn get_card(&mut self, cid: CardID) -> Result> { + // the casts are required as Anki didn't prevent add-ons from + // storing strings or floats in columns before + let stmt = cached_sql!( + self.get_card_stmt, + self.db, + " +select nid, did, ord, cast(mod as integer), usn, type, queue, due, +cast(ivl as integer), factor, reps, lapses, left, odue, odid, +flags, data from cards where id=?" + ); + + stmt.query_row(params![cid], |row| { + Ok(Card { + id: cid, + nid: row.get(0)?, + did: row.get(1)?, + ord: row.get(2)?, + mtime: row.get(3)?, + usn: row.get(4)?, + ctype: row.get(5)?, + queue: row.get(6)?, + due: row.get(7)?, + ivl: row.get(8)?, + factor: row.get(9)?, + reps: row.get(10)?, + lapses: row.get(11)?, + left: row.get(12)?, + odue: row.get(13)?, + odid: row.get(14)?, + flags: row.get(15)?, + data: row.get(16)?, + }) + }) + .optional() + .map_err(Into::into) + } + + pub(crate) fn update_card(&mut self, card: &Card) -> Result<()> { + if card.id.0 == 0 { + return Err(AnkiError::invalid_input("card id not set")); + } + self.flush_card(card) + } + + fn flush_card(&mut self, card: &Card) -> Result<()> { + let stmt = cached_sql!( + self.update_card_stmt, + self.db, + " +insert or replace into cards +(id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, +reps, lapses, left, odue, odid, flags, data) +values +(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +" + ); + + stmt.execute(params![ + card.id, + card.nid, + card.did, + card.ord, + card.mtime, + card.usn, + card.ctype as u8, + card.queue as i8, + card.due, + card.ivl, + card.factor, + card.reps, + card.lapses, + card.left, + card.odue, + card.odid, + card.flags, + card.data, + ])?; + + Ok(()) + } +} diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index 2ed04892c..7a21f0336 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -1,3 +1,4 @@ +mod card; mod sqlite; pub(crate) use sqlite::{SqliteStorage, StorageContext}; diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index b56510717..b57a3fd4f 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -3,15 +3,17 @@ use crate::collection::CollectionOp; use crate::config::Config; +use crate::decks::DeckID; use crate::err::Result; use crate::err::{AnkiError, DBErrorKind}; -use crate::time::{i64_unix_millis, i64_unix_secs}; +use crate::notetypes::NoteTypeID; +use crate::timestamp::{TimestampMillis, TimestampSecs}; use crate::{ decks::Deck, notetypes::NoteType, sched::cutoff::{sched_timing_today, SchedTimingToday}, text::without_combining, - types::{ObjID, Usn}, + types::Usn, }; use regex::Regex; use rusqlite::{functions::FunctionFlags, params, Connection, NO_PARAMS}; @@ -40,6 +42,16 @@ pub struct SqliteStorage { path: PathBuf, } +#[macro_export] +macro_rules! cached_sql { + ( $label:expr, $db:expr, $sql:expr ) => {{ + if $label.is_none() { + $label = Some($db.prepare_cached($sql)?); + } + $label.as_mut().unwrap() + }}; +} + fn open_or_create_collection_db(path: &Path) -> Result { let mut db = Connection::open(path)?; @@ -168,7 +180,10 @@ impl SqliteStorage { if create { db.prepare_cached("begin exclusive")?.execute(NO_PARAMS)?; db.execute_batch(include_str!("schema11.sql"))?; - db.execute("update col set crt=?, ver=?", params![i64_unix_secs(), ver])?; + db.execute( + "update col set crt=?, ver=?", + params![TimestampSecs::now(), ver], + )?; db.prepare_cached("commit")?.execute(NO_PARAMS)?; } else { if ver > SCHEMA_MAX_VERSION { @@ -200,12 +215,14 @@ impl SqliteStorage { pub(crate) struct StorageContext<'a> { pub(crate) db: &'a Connection, - #[allow(dead_code)] server: bool, - #[allow(dead_code)] usn: Option, timing_today: Option, + + // cards + pub(super) get_card_stmt: Option>, + pub(super) update_card_stmt: Option>, } impl StorageContext<'_> { @@ -215,6 +232,8 @@ impl StorageContext<'_> { server, usn: None, timing_today: None, + get_card_stmt: None, + update_card_stmt: None, } } @@ -278,11 +297,10 @@ impl StorageContext<'_> { pub(crate) fn mark_modified(&self) -> Result<()> { self.db .prepare_cached("update col set mod=?")? - .execute(params![i64_unix_millis()])?; + .execute(params![TimestampMillis::now()])?; Ok(()) } - #[allow(dead_code)] pub(crate) fn usn(&mut self) -> Result { if self.server { if self.usn.is_none() { @@ -292,13 +310,13 @@ impl StorageContext<'_> { .query_row(NO_PARAMS, |row| row.get(0))?, ); } - Ok(*self.usn.as_ref().unwrap()) + Ok(self.usn.clone().unwrap()) } else { - Ok(-1) + Ok(Usn(-1)) } } - pub(crate) fn all_decks(&self) -> Result> { + pub(crate) fn all_decks(&self) -> Result> { self.db .query_row_and_then("select decks from col", NO_PARAMS, |row| -> Result<_> { Ok(serde_json::from_str(row.get_raw(0).as_str()?)?) @@ -312,11 +330,12 @@ impl StorageContext<'_> { }) } - pub(crate) fn all_note_types(&self) -> Result> { + pub(crate) fn all_note_types(&self) -> Result> { let mut stmt = self.db.prepare("select models from col")?; let note_types = stmt - .query_and_then(NO_PARAMS, |row| -> Result> { - let v: HashMap = serde_json::from_str(row.get_raw(0).as_str()?)?; + .query_and_then(NO_PARAMS, |row| -> Result> { + let v: HashMap = + serde_json::from_str(row.get_raw(0).as_str()?)?; Ok(v) })? .next() @@ -339,7 +358,7 @@ impl StorageContext<'_> { self.timing_today = Some(sched_timing_today( crt, - i64_unix_secs(), + TimestampSecs::now().0, conf.creation_offset, now_offset, conf.rollover, diff --git a/rslib/src/time.rs b/rslib/src/timestamp.rs similarity index 72% rename from rslib/src/time.rs rename to rslib/src/timestamp.rs index e392cbb6e..b1a419579 100644 --- a/rslib/src/time.rs +++ b/rslib/src/timestamp.rs @@ -1,14 +1,22 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use crate::define_newtype; use std::time; -pub(crate) fn i64_unix_secs() -> i64 { - elapsed().as_secs() as i64 +define_newtype!(TimestampSecs, i64); +define_newtype!(TimestampMillis, i64); + +impl TimestampSecs { + pub fn now() -> Self { + Self(elapsed().as_secs() as i64) + } } -pub(crate) fn i64_unix_millis() -> i64 { - elapsed().as_millis() as i64 +impl TimestampMillis { + pub fn now() -> Self { + Self(elapsed().as_millis() as i64) + } } #[cfg(not(test))] diff --git a/rslib/src/types.rs b/rslib/src/types.rs index 0ae6e2a83..6d672d61e 100644 --- a/rslib/src/types.rs +++ b/rslib/src/types.rs @@ -1,9 +1,57 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -// while Anki tends to only use positive numbers, sqlite only supports -// signed integers, so these numbers are signed as well. +#[macro_export] +macro_rules! define_newtype { + ( $name:ident, $type:ident ) => { + #[repr(transparent)] + #[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + serde::Serialize, + serde::Deserialize, + )] + pub struct $name(pub $type); -pub type ObjID = i64; -pub type Usn = i32; -pub type Timestamp = i64; + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } + } + + impl std::str::FromStr for $name { + type Err = std::num::ParseIntError; + fn from_str(s: &std::primitive::str) -> std::result::Result { + $type::from_str(s).map($name) + } + } + + impl rusqlite::types::FromSql for $name { + fn column_result( + value: rusqlite::types::ValueRef<'_>, + ) -> std::result::Result { + if let rusqlite::types::ValueRef::Integer(i) = value { + Ok(Self(i as $type)) + } else { + Err(rusqlite::types::FromSqlError::InvalidType) + } + } + } + + impl rusqlite::ToSql for $name { + fn to_sql(&self) -> ::rusqlite::Result> { + Ok(rusqlite::types::ToSqlOutput::Owned( + rusqlite::types::Value::Integer(self.0 as i64), + )) + } + } + }; +} + +define_newtype!(Usn, i32);