diff --git a/proto/backend.proto b/proto/backend.proto index d8b12e3d7..6a505c37c 100644 --- a/proto/backend.proto +++ b/proto/backend.proto @@ -71,7 +71,7 @@ message BackendInput { int32 get_changed_notetypes = 56; AddOrUpdateNotetypeIn add_or_update_notetype = 57; Empty get_all_decks = 58; - bytes set_all_decks = 59; +// bytes set_all_decks = 59; Empty all_stock_notetypes = 60; int64 get_notetype_legacy = 61; Empty get_notetype_names = 62; @@ -83,6 +83,12 @@ message BackendInput { Note update_note = 68; int64 get_note = 69; Empty get_empty_cards = 70; + int64 get_deck_legacy = 71; + string get_deck_id_by_name = 72; + Empty get_deck_names = 73; + AddOrUpdateDeckLegacyIn add_or_update_deck_legacy = 74; + bool new_deck_legacy = 75; + int64 remove_deck = 76; } } @@ -134,7 +140,7 @@ message BackendOutput { bytes get_changed_notetypes = 56; int64 add_or_update_notetype = 57; bytes get_all_decks = 58; - Empty set_all_decks = 59; +// Empty set_all_decks = 59; bytes get_notetype_legacy = 61; NoteTypeNames get_notetype_names = 62; NoteTypeUseCounts get_notetype_names_and_counts = 63; @@ -145,6 +151,12 @@ message BackendOutput { Empty update_note = 68; Note get_note = 69; EmptyCardsReport get_empty_cards = 70; + bytes get_deck_legacy = 71; + int64 get_deck_id_by_name = 72; + DeckNames get_deck_names = 73; + int64 add_or_update_deck_legacy = 74; + bytes new_deck_legacy = 75; + Empty remove_deck = 76; BackendError error = 2047; } @@ -166,6 +178,8 @@ message BackendError { string json_error = 9; string proto_error = 10; Empty not_found_error = 11; + Empty exists = 12; + Empty deck_is_filtered = 13; } } @@ -614,3 +628,80 @@ message NoteWithEmptyCards { repeated int64 card_ids = 2; bool will_delete_note = 3; } + +message Deck { + int64 id = 1; + string name = 2; + uint32 mtime_secs = 3; + int32 usn = 4; + DeckCommon common = 5; + oneof kind { + NormalDeck normal = 6; + FilteredDeck filtered = 7; + } +} + +message DeckCommon { + bool collapsed = 1; + uint32 last_day_studied = 2; + int32 new_studied = 3; + int32 review_studied = 4; + int32 learning_studied = 5; + int32 secs_studied = 6; + bytes other = 16; +} + +message DeckKind { + oneof kind { + NormalDeck normal = 1; + FilteredDeck filtered = 2; + } +} + +message NormalDeck { + int64 config_id = 1; + uint32 extend_new = 2; + uint32 extend_review = 3; + string description = 4; +} + +message FilteredDeck { + bool reschedule = 1; + repeated FilteredSearchTerm search_terms = 2; + // v1 scheduler only + repeated float delays = 3; + // v2 scheduler only + uint32 preview_delay = 4; +} + +message FilteredSearchTerm { + string search = 1; + uint32 limit = 2; + FilteredSearchOrder order = 3; +} + +enum FilteredSearchOrder { + FILTERED_SEARCH_ORDER_OLDEST_FIRST = 0; + FILTERED_SEARCH_ORDER_RANDOM = 1; + FILTERED_SEARCH_ORDER_INTERVALS_ASCENDING = 2; + FILTERED_SEARCH_ORDER_INTERVALS_DESCENDING = 3; + FILTERED_SEARCH_ORDER_LAPSES = 4; + FILTERED_SEARCH_ORDER_ADDED = 5; + FILTERED_SEARCH_ORDER_DUE = 6; + FILTERED_SEARCH_ORDER_REVERSE_ADDED = 7; + FILTERED_SEARCH_ORDER_DUE_PRIORITY = 8; +} + +message DeckNames { + repeated DeckNameID entries = 1; +} + +message DeckNameID { + int64 id = 1; + string name = 2; +} + +message AddOrUpdateDeckLegacyIn { + bytes deck = 1; + bool preserve_usn_and_mtime = 2; +} diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 8afbd45a8..33ea1cc97 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -158,14 +158,10 @@ class _Collection: self.dty, # no longer used self._usn, self.ls, - decks, ) = self.db.first( """ -select crt, mod, scm, dty, usn, ls, -decks from col""" +select crt, mod, scm, dty, usn, ls from col""" ) - self.decks.decks = self.backend.get_all_decks() - self.decks.changed = False def setMod(self) -> None: """Mark DB modified. diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 0ae47b9c9..d1c6ec379 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -8,6 +8,7 @@ import unicodedata from typing import Any, Dict, List, Optional, Set, Tuple, Union import anki # pylint: disable=unused-import +import anki.backend_pb2 as pb from anki import hooks from anki.consts import * from anki.errors import DeckRenameError @@ -25,11 +26,13 @@ defaultDeck = { "conf": 1, "usn": 0, "desc": "", - "dyn": DECK_STD, # anki uses int/bool interchangably here + "dyn": DECK_STD, "collapsed": False, # added in beta11 "extendNew": 10, "extendRev": 50, + # fixme: if we keep this, mod must be set or handled in serde + "mod": 0, } defaultDynamicDeck = { @@ -49,119 +52,140 @@ defaultDynamicDeck = { "return": True, # currently unused # v2 scheduler "previewDelay": 10, + "mod": 0, } -class DeckManager: - decks: Dict[str, Any] +class DecksDictProxy: + def __init__(self, col: anki.storage._Collection): + self._col = col.weakref() + def _warn(self): + print("add-on should use methods on col.decks, not col.decks.decks dict") + + def __getitem__(self, item): + self._warn() + return self._col.decks.get(int(item)) + + def __setitem__(self, key, val): + self._warn() + self._col.decks.save(val) + + def __len__(self): + self._warn() + return len(self._col.decks.all_names_and_ids()) + + def keys(self): + self._warn() + return [str(nt.id) for nt in self._col.decks.all_names_and_ids()] + + def values(self): + self._warn() + return self._col.decks.all() + + def items(self): + self._warn() + return [(str(nt["id"]), nt) for nt in self._col.decks.all()] + + def __contains__(self, item): + self._warn() + self._col.decks.have(item) + + +class DeckManager: # Registry save/load ############################################################# def __init__(self, col: anki.storage._Collection) -> None: self.col = col.weakref() - self.decks = {} + self.decks = DecksDictProxy(col) + # do not access this directly! + # self._cache: Dict[int, ] = {} + # self.decks = {} self._dconf_cache: Optional[Dict[int, Dict[str, Any]]] = None - def save(self, g: Optional[Any] = None) -> None: + def save(self, g: Dict = None) -> None: "Can be called with either a deck or a deck configuration." - if g: - # deck conf? - if "maxTaken" in g: - self.update_config(g) - return - else: - g["mod"] = intTime() - g["usn"] = self.col.usn() - self.changed = True + if not g: + print("col.decks.save() should be passed the changed deck") + return - def flush(self) -> None: - if self.changed: - self.col.backend.set_all_decks(self.decks) - self.changed = False + # deck conf? + if "maxTaken" in g: + self.update_config(g) + return + else: + # g["mod"] = intTime() + # g["usn"] = self.col.usn() + self.update(g) + + # legacy + def flush(self): + pass # Deck save/load ############################################################# + # fixme: if we're stripping chars on add, then we need to do that on lookup as well + # and need to make sure \x1f conversion + def id( self, name: str, create: bool = True, type: Optional[Dict[str, Any]] = None ) -> Optional[int]: "Add a deck with NAME. Reuse deck if already exists. Return id as int." if type is None: type = defaultDeck - name = name.replace('"', "") - name = unicodedata.normalize("NFC", name) - deck = self.byName(name) - if deck: - return int(deck["id"]) - if not create: - return None - g = copy.deepcopy(type) - if "::" in name: - # not top level; ensure all parents exist - name = self._ensureParents(name) - g["name"] = name - while 1: - id = intTime(1000) - if str(id) not in self.decks: - break - g["id"] = id - self.decks[str(id)] = g - self.save(g) - self.maybeAddToActive() - hooks.deck_added(g) - return int(id) - def rem(self, did: int, cardsToo: bool = False, childrenToo: bool = True) -> None: + id = self.id_for_name(name) + if id: + return id + elif not create: + return None + + deck = self.new_deck_legacy(bool(type["dyn"])) + deck["name"] = name + self.update(deck) + + # fixme + self.maybeAddToActive() + + # fixme + hooks.deck_added(deck) + + return deck["id"] + + def rem(self, did: int, cardsToo: bool = True, childrenToo: bool = True) -> None: "Remove the deck. If cardsToo, delete any cards inside." - if str(did) == "1": - # we won't allow the default deck to be deleted, but if it's a - # child of an existing deck then it needs to be renamed - deck = self.get(did) - if "::" in deck["name"]: - base = self.basename(deck["name"]) - suffix = "" - while True: - # find an unused name - name = base + suffix - if not self.byName(name): - deck["name"] = name - self.save(deck) - break - suffix += "1" - return - # log the removal regardless of whether we have the deck or not - self.col._logRem([did], REM_DECK) - # do nothing else if doesn't exist - if not str(did) in self.decks: - return - deck = self.get(did) - if deck["dyn"]: - # deleting a cramming deck returns cards to their previous deck - # rather than deleting the cards - self.col.sched.emptyDyn(did) - if childrenToo: - for name, id in self.children(did): - self.rem(id, cardsToo, childrenToo=False) - else: - # delete children first - if childrenToo: - # we don't want to delete children when syncing - for name, id in self.children(did): - self.rem(id, cardsToo, childrenToo=False) - # delete cards too? - if cardsToo: - # don't use cids(), as we want cards in cram decks too - cids = self.col.db.list( - "select id from cards where did=? or odid=?", did, did - ) - self.col.remCards(cids) - # delete the deck and add a grave - del self.decks[str(did)] + assert cardsToo and childrenToo + self.col.backend.remove_deck(did) + # fixme: default deck special case + # if str(did) == "1": + # # we won't allow the default deck to be deleted, but if it's a + # # child of an existing deck then it needs to be renamed + # deck = self.get(did) + # if "::" in deck["name"]: + # base = self.basename(deck["name"]) + # suffix = "" + # while True: + # # find an unused name + # name = base + suffix + # if not self.byName(name): + # deck["name"] = name + # self.save(deck) + # break + # suffix += "1" + # return + + # fixme: + # # don't use cids(), as we want cards in cram decks too + # cids = self.col.db.list( + # "select id from cards where did=? or odid=?", did, did + # ) + + # fixme # ensure we have an active deck if did in self.active(): - self.select(int(list(self.decks.keys())[0])) - self.save() + self.select(self.all_names_and_ids()[0].id) def allNames(self, dyn: bool = True, force_default: bool = True) -> List: "An unsorted list of all deck names." @@ -172,6 +196,29 @@ class DeckManager: x["name"] for x in self.all(force_default=force_default) if not x["dyn"] ] + def all_names_and_ids(self) -> List[pb.DeckNameID]: + return self.col.backend.get_deck_names_and_ids() + + def id_for_name(self, name: str) -> Optional[int]: + return self.col.backend.get_deck_id_by_name(name) + + def get_legacy(self, did: int) -> Optional[Dict]: + return self.col.backend.get_deck_legacy(did) + + def have(self, id: int) -> bool: + return not self.get_legacy(int(id)) + + def get_all_legacy(self) -> List[Dict]: + return list(self.col.backend.get_all_decks().values()) + + def new_deck_legacy(self, filtered: bool) -> Dict: + try: + return self.col.backend.new_deck_legacy(filtered) + except anki.rsbackend.DeckIsFilteredError: + raise DeckRenameError("deck was filtered") + except anki.rsbackend.ExistsError: + raise DeckRenameError("deck already exists") + def all(self, force_default: bool = True) -> List: """A list of all decks. @@ -181,19 +228,20 @@ class DeckManager: * default deck contains a card * default deck has a child (assumed not to be the case if assume_no_child) """ - decks = list(self.decks.values()) + decks = self.get_all_legacy() if not force_default and not self.should_default_be_displayed(force_default): decks = [deck for deck in decks if deck["id"] != 1] return decks def allIds(self) -> List[str]: - return list(self.decks.keys()) + return [str(x.id) for x in self.all_names_and_ids()] def collapse(self, did) -> None: deck = self.get(did) deck["collapsed"] = not deck["collapsed"] self.save(deck) + # fixme def collapseBrowser(self, did) -> None: deck = self.get(did) collapsed = deck.get("browserCollapsed", False) @@ -201,51 +249,54 @@ class DeckManager: self.save(deck) def count(self) -> int: - return len(self.decks) + return len(self.all_names_and_ids()) - def get(self, did: Union[int, str], default: bool = True) -> Any: - id = str(did) - if id in self.decks: - return self.decks[id] + def get(self, did: Union[int, str], default: bool = True) -> Optional[Dict]: + if not did: + if default: + return self.get_legacy(1) + else: + return None + id = int(did) + deck = self.get_legacy(id) + if deck: + return deck elif default: - return self.decks["1"] + return self.get_legacy(1) + else: + return None - def byName(self, name: str) -> Any: + def byName(self, name: str) -> Optional[Dict]: """Get deck with NAME, ignoring case.""" - for m in list(self.decks.values()): - if self.equalName(m["name"], name): - return m + id = self.id_for_name(name) + if id: + return self.get_legacy(id) + return None - def update(self, g: Dict[str, Any]) -> None: + def update(self, g: Dict[str, Any], preserve_usn=False) -> None: "Add or update an existing deck. Used for syncing and merging." - self.decks[str(g["id"])] = g + try: + self.col.backend.add_or_update_deck_legacy(g, preserve_usn) + except anki.rsbackend.DeckIsFilteredError: + raise DeckRenameError("deck was filtered") + except anki.rsbackend.ExistsError: + raise DeckRenameError("deck already exists") + + # self.decks[str(g["id"])] = g self.maybeAddToActive() # mark registry changed, but don't bump mod time - self.save() + + # self.save() def rename(self, g: Dict[str, Any], newName: str) -> None: "Rename deck prefix to NAME if not exists. Updates children." - # make sure target node doesn't already exist - if self.byName(newName): - raise DeckRenameError(_("That deck already exists.")) - # make sure we're not nesting under a filtered deck - for p in self.parentsByName(newName): - if p["dyn"]: - raise DeckRenameError(_("A filtered deck cannot have subdecks.")) - # ensure we have parents - newName = self._ensureParents(newName) - # rename children - for grp in self.all(): - if grp["name"].startswith(g["name"] + "::"): - grp["name"] = grp["name"].replace(g["name"] + "::", newName + "::", 1) - self.save(grp) - # adjust name g["name"] = newName - # ensure we have parents again, as we may have renamed parent->child - newName = self._ensureParents(newName) - self.save(g) - # renaming may have altered active did order - self.maybeAddToActive() + self.update(g) + return + + # fixme: ensure rename of b in a::b::c generates new b + # fixme: renaming may have altered active did order + # self.maybeAddToActive() def renameForDragAndDrop(self, draggedDeckDid: int, ontoDeckDid: Any) -> None: draggedDeck = self.get(draggedDeckDid) @@ -389,6 +440,7 @@ class DeckManager: grp["conf"] = id self.save(grp) + # fixme: expensive def didsForConf(self, conf) -> List: dids = [] for deck in list(self.decks.values()): @@ -459,16 +511,18 @@ class DeckManager: def for_card_ids(self, cids: List[int]) -> List[int]: return self.col.db.list(f"select did from cards where id in {ids2str(cids)}") + # fixme def _recoverOrphans(self) -> None: - dids = list(self.decks.keys()) - mod = self.col.db.mod - self.col.db.execute( - "update cards set did = 1 where did not in " + ids2str(dids) - ) - self.col.db.mod = mod + pass + # dids = list(self.decks.keys()) + # mod = self.col.db.mod + # self.col.db.execute( + # "update cards set did = 1 where did not in " + ids2str(dids) + # ) + # self.col.db.mod = mod def _checkDeckTree(self) -> None: - decks = self.col.decks.all() + decks = self.all() decks.sort(key=self.key) names: Set[str] = set() @@ -522,18 +576,20 @@ class DeckManager: return True if self.col.db.scalar("select 1 from cards where did = 1 limit 1"): return True - if len(self.decks) == 1: - return True - # looking for children - if assume_no_child: - return False - if default_deck is None: - default_deck = self.get(1) - defaultName = default_deck["name"] - for name in self.allNames(): - if name.startswith(f"{defaultName}::"): - return True + # fixme return False + # if len(self.all_names_and_ids()) == 1: + # return True + # # looking for children + # if assume_no_child: + # return False + # if default_deck is None: + # default_deck = self.get(1) + # defaultName = default_deck["name"] + # for name in self.allNames(): + # if name.startswith(f"{defaultName}::"): + # return True + # return False # Deck selection ############################################################# @@ -632,7 +688,7 @@ class DeckManager: return parents def nameMap(self) -> dict: - return dict((d["name"], d) for d in self.decks.values()) + return dict((d["name"], d) for d in self.all()) # Sync handling ########################################################################## diff --git a/pylib/anki/models.py b/pylib/anki/models.py index 6a1de1d6d..9dfab2eb1 100644 --- a/pylib/anki/models.py +++ b/pylib/anki/models.py @@ -191,9 +191,6 @@ class ModelManager: def all_use_counts(self) -> List[pb.NoteTypeNameIDUseCount]: return self.col.backend.get_notetype_use_counts() - def id_for_name(self, name: str) -> Optional[int]: - return self.col.backend.get_notetype_id_by_name(name) - # legacy def allNames(self) -> List[str]: @@ -227,6 +224,9 @@ class ModelManager: # Retrieving and creating models ############################################################# + def id_for_name(self, name: str) -> Optional[int]: + return self.col.backend.get_notetype_id_by_name(name) + def get(self, id: int) -> Optional[NoteType]: "Get model with ID, or None." # deal with various legacy input types diff --git a/pylib/anki/rsbackend.py b/pylib/anki/rsbackend.py index 34e7cb957..7edf4fe97 100644 --- a/pylib/anki/rsbackend.py +++ b/pylib/anki/rsbackend.py @@ -102,6 +102,14 @@ class NotFoundError(Exception): pass +class ExistsError(Exception): + pass + + +class DeckIsFilteredError(Exception): + pass + + def proto_exception_to_native(err: pb.BackendError) -> Exception: val = err.WhichOneof("value") if val == "interrupted": @@ -122,6 +130,10 @@ def proto_exception_to_native(err: pb.BackendError) -> Exception: return StringError(err.localized) elif val == "not_found_error": return NotFoundError() + elif val == "exists": + return ExistsError() + elif val == "deck_is_filtered": + return DeckIsFilteredError() else: assert_impossible_literal(val) @@ -597,9 +609,6 @@ class RustBackend: ).get_all_decks return orjson.loads(jstr) - def set_all_decks(self, nts: Dict[str, Dict[str, Any]]): - self._run_command(pb.BackendInput(set_all_decks=orjson.dumps(nts))) - def all_stock_notetypes(self) -> List[NoteType]: return list( self._run_command( @@ -674,6 +683,52 @@ class RustBackend: pb.BackendInput(get_empty_cards=pb.Empty()), release_gil=True ).get_empty_cards + def get_deck_legacy(self, did: int) -> Optional[Dict]: + try: + bytes = self._run_command( + pb.BackendInput(get_deck_legacy=did) + ).get_deck_legacy + return orjson.loads(bytes) + except NotFoundError: + return None + + def get_deck_names_and_ids(self) -> List[pb.DeckNameID]: + return list( + self._run_command( + pb.BackendInput(get_deck_names=pb.Empty()) + ).get_deck_names.entries + ) + + def add_or_update_deck_legacy( + self, deck: Dict[str, Any], preserve_usn: bool + ) -> None: + deck_json = orjson.dumps(deck) + id = self._run_command( + pb.BackendInput( + add_or_update_deck_legacy=pb.AddOrUpdateDeckLegacyIn( + deck=deck_json, preserve_usn_and_mtime=preserve_usn + ) + ) + ).add_or_update_deck_legacy + deck["id"] = id + + def new_deck_legacy(self, filtered: bool) -> Dict[str, Any]: + jstr = self._run_command( + pb.BackendInput(new_deck_legacy=filtered) + ).new_deck_legacy + return orjson.loads(jstr) + + def get_deck_id_by_name(self, name: str) -> Optional[int]: + return ( + self._run_command( + pb.BackendInput(get_deck_id_by_name=name) + ).get_deck_id_by_name + or None + ) + + def remove_deck(self, did: int) -> None: + self._run_command(pb.BackendInput(remove_deck=did)) + def translate_string_in( key: TR, **kwargs: Union[str, int, float] diff --git a/pylib/anki/storage.py b/pylib/anki/storage.py index 82da0dc0c..daa697f9c 100644 --- a/pylib/anki/storage.py +++ b/pylib/anki/storage.py @@ -1,8 +1,6 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html -import copy -import json import os import weakref from dataclasses import dataclass @@ -10,10 +8,8 @@ from typing import Optional from anki.collection import _Collection from anki.dbproxy import DBProxy -from anki.lang import _ from anki.media import media_paths_from_col_path from anki.rsbackend import RustBackend -from anki.utils import intTime @dataclass @@ -43,37 +39,7 @@ def Collection( backend.open_collection(path, media_dir, media_db, log_path) db = DBProxy(weakref.proxy(backend), path) - # initial setup required? - create = db.scalar("select decks = '{}' from col") - if create: - initial_db_setup(db) - # add db to col and do any remaining upgrades - col = _Collection(db, backend=backend, server=server, log=should_log) - if create: - col.save() - else: - db.begin() - return col - - -# Creating a new collection -###################################################################### - - -def initial_db_setup(db: DBProxy) -> None: - import anki.decks - + col = _Collection(db, backend=backend, server=server) db.begin() - - g = copy.deepcopy(anki.decks.defaultDeck) - g["id"] = 1 - g["name"] = _("Default") - g["conf"] = 1 - g["mod"] = intTime() - - db.execute( - """ -update col set decks = ?""", - json.dumps({"1": g}), - ) + return col diff --git a/pylib/tests/test_decks.py b/pylib/tests/test_decks.py index b53510d27..69a465305 100644 --- a/pylib/tests/test_decks.py +++ b/pylib/tests/test_decks.py @@ -7,13 +7,13 @@ from tests.shared import assertException, getEmptyCol def test_basic(): deck = getEmptyCol() # we start with a standard deck - assert len(deck.decks.decks) == 1 + assert len(deck.decks.all_names_and_ids()) == 1 # it should have an id of 1 assert deck.decks.name(1) # create a new deck parentId = deck.decks.id("new deck") assert parentId - assert len(deck.decks.decks) == 2 + assert len(deck.decks.all_names_and_ids()) == 2 # should get the same id assert deck.decks.id("new deck") == parentId # we start with the default deck selected @@ -54,22 +54,11 @@ def test_remove(): deck.addNote(f) c = f.cards()[0] assert c.did == g1 - # by default deleting the deck leaves the cards with an invalid did assert deck.cardCount() == 1 deck.decks.rem(g1) - assert deck.cardCount() == 1 - c.load() - assert c.did == g1 - # but if we try to get it, we get the default - assert deck.decks.name(c.did) == "[no deck]" - # let's create another deck and explicitly set the card to it - g2 = deck.decks.id("g2") - c.did = g2 - c.flush() - # this time we'll delete the card/note too - deck.decks.rem(g2, cardsToo=True) assert deck.cardCount() == 0 - assert deck.noteCount() == 0 + # if we try to get it, we get the default + assert deck.decks.name(c.did) == "[no deck]" def test_rename(): diff --git a/pylib/tests/test_schedv1.py b/pylib/tests/test_schedv1.py index c323f0912..6afdbcccf 100644 --- a/pylib/tests/test_schedv1.py +++ b/pylib/tests/test_schedv1.py @@ -630,6 +630,7 @@ def test_cram(): assert d.sched.nextIvl(c, 2) == 138 * 60 * 60 * 24 cram = d.decks.get(did) cram["delays"] = [1, 10] + d.decks.save(cram) assert d.sched.answerButtons(c) == 3 assert d.sched.nextIvl(c, 1) == 60 assert d.sched.nextIvl(c, 2) == 600 @@ -739,6 +740,7 @@ def test_cram_resched(): did = d.decks.newDyn("Cram") cram = d.decks.get(did) cram["resched"] = False + d.decks.save(cram) d.sched.rebuildDyn(did) d.reset() # graduate should return it to new @@ -1017,7 +1019,7 @@ def test_deckDue(): foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() - assert len(d.decks.decks) == 5 + assert len(d.decks.all_names_and_ids()) == 5 cnts = d.sched.deckDueList() assert cnts[0] == ["Default", 1, 0, 0, 1] assert cnts[1] == ["Default::1", default1, 1, 0, 0] diff --git a/pylib/tests/test_schedv2.py b/pylib/tests/test_schedv2.py index 4e1d822d3..a2e7202e8 100644 --- a/pylib/tests/test_schedv2.py +++ b/pylib/tests/test_schedv2.py @@ -460,6 +460,8 @@ def test_review_limits(): assert tree[1][5][0][2] == 4 # child # switch limits + parent = d.decks.get(parent["id"]) + child = d.decks.get(child["id"]) d.decks.setConf(parent, cconf["id"]) d.decks.setConf(child, pconf["id"]) d.decks.select(parent["id"]) @@ -825,6 +827,7 @@ def test_preview(): did = d.decks.newDyn("Cram") cram = d.decks.get(did) cram["resched"] = False + d.decks.save(cram) d.sched.rebuildDyn(did) d.reset() # grab the first card @@ -1032,7 +1035,7 @@ def test_deckDue(): foobaz = f.model()["did"] = d.decks.id("foo::baz") d.addNote(f) d.reset() - assert len(d.decks.decks) == 5 + assert len(d.decks.all_names_and_ids()) == 5 cnts = d.sched.deckDueList() assert cnts[0] == ["Default", 1, 1, 0, 1] assert cnts[1] == ["Default::1", default1, 1, 0, 0] diff --git a/rslib/src/backend/mod.rs b/rslib/src/backend/mod.rs index 1d862c3da..32e12a64e 100644 --- a/rslib/src/backend/mod.rs +++ b/rslib/src/backend/mod.rs @@ -11,7 +11,7 @@ use crate::{ collection::{open_collection, Collection}, config::SortKind, deckconf::{DeckConf, DeckConfID}, - decks::{Deck, DeckID}, + decks::{Deck, DeckID, DeckSchema11}, err::{AnkiError, NetworkErrorKind, Result, SyncErrorKind}, i18n::{tr_args, I18n, TR}, latex::{extract_latex, extract_latex_expanding_clozes, ExtractedLatex}, @@ -78,6 +78,8 @@ fn anki_error_to_proto_error(err: AnkiError, i18n: &I18n) -> pb::BackendError { AnkiError::JSONError { info } => V::JsonError(info), AnkiError::ProtoError { info } => V::ProtoError(info), AnkiError::NotFound => V::NotFoundError(Empty {}), + AnkiError::Existing => V::Exists(Empty {}), + AnkiError::DeckIsFiltered => V::DeckIsFiltered(Empty {}), }; pb::BackendError { @@ -306,10 +308,6 @@ impl Backend { OValue::GetChangedNotetypes(self.get_changed_notetypes()?) } Value::GetAllDecks(_) => OValue::GetAllDecks(self.get_all_decks()?), - Value::SetAllDecks(bytes) => { - self.set_all_decks(&bytes)?; - OValue::SetAllDecks(pb::Empty {}) - } Value::AllStockNotetypes(_) => OValue::AllStockNotetypes(pb::AllStockNotetypesOut { notetypes: all_stock_notetypes(&self.i18n) .into_iter() @@ -342,6 +340,22 @@ impl Backend { } Value::GetNote(nid) => OValue::GetNote(self.get_note(nid)?), Value::GetEmptyCards(_) => OValue::GetEmptyCards(self.get_empty_cards()?), + Value::GetDeckLegacy(did) => OValue::GetDeckLegacy(self.get_deck_legacy(did)?), + Value::GetDeckIdByName(name) => { + OValue::GetDeckIdByName(self.get_deck_id_by_name(&name)?) + } + Value::GetDeckNames(_) => OValue::GetDeckNames(self.get_deck_names()?), + Value::AddOrUpdateDeckLegacy(input) => { + OValue::AddOrUpdateDeckLegacy(self.add_or_update_deck_legacy(input)?) + } + Value::NewDeckLegacy(filtered) => { + OValue::NewDeckLegacy(self.new_deck_legacy(filtered)?) + } + + Value::RemoveDeck(did) => OValue::RemoveDeck({ + self.remove_deck(did)?; + pb::Empty {} + }), }) } @@ -846,14 +860,9 @@ impl Backend { // }) } - fn set_all_decks(&self, json: &[u8]) -> Result<()> { - let val: HashMap = serde_json::from_slice(json)?; - self.with_col(|col| col.transact(None, |col| col.storage.set_all_decks(val))) - } - fn get_all_decks(&self) -> Result> { self.with_col(|col| { - let decks = col.storage.get_all_decks()?; + let decks = col.storage.get_all_decks_as_schema11()?; serde_json::to_vec(&decks).map_err(Into::into) }) } @@ -974,6 +983,59 @@ impl Backend { }) }) } + + fn get_deck_legacy(&self, did: i64) -> Result> { + self.with_col(|col| { + let deck: DeckSchema11 = col + .storage + .get_deck(DeckID(did))? + .ok_or(AnkiError::NotFound)? + .into(); + serde_json::to_vec(&deck).map_err(Into::into) + }) + } + + fn get_deck_id_by_name(&self, human_name: &str) -> Result { + self.with_col(|col| { + col.get_deck_id(human_name) + .map(|d| d.map(|d| d.0).unwrap_or_default()) + }) + } + + fn get_deck_names(&self) -> Result { + self.with_col(|col| { + let names = col.storage.get_all_deck_names()?; + Ok(pb::DeckNames { + entries: names + .into_iter() + .map(|(id, name)| pb::DeckNameId { id: id.0, name }) + .collect(), + }) + }) + } + + fn add_or_update_deck_legacy(&self, input: pb::AddOrUpdateDeckLegacyIn) -> Result { + self.with_col(|col| { + let schema11: DeckSchema11 = serde_json::from_slice(&input.deck)?; + let mut deck: Deck = schema11.into(); + col.add_or_update_deck(&mut deck, input.preserve_usn_and_mtime)?; + Ok(deck.id.0) + }) + } + + fn new_deck_legacy(&self, filtered: bool) -> Result> { + let deck = if filtered { + Deck::new_filtered() + } else { + Deck::new_normal() + }; + let schema11: DeckSchema11 = deck.into(); + serde_json::to_vec(&schema11).map_err(Into::into) + } + + fn remove_deck(&self, did: i64) -> Result<()> { + self.with_col(|col| col.remove_deck_and_child_decks(DeckID(did))) + } } fn translate_arg_to_fluent_val(arg: &pb::TranslateArgValue) -> FluentValue { diff --git a/rslib/src/card.rs b/rslib/src/card.rs index b4a8fa99d..65b3d5b16 100644 --- a/rslib/src/card.rs +++ b/rslib/src/card.rs @@ -5,9 +5,13 @@ use crate::decks::DeckID; use crate::define_newtype; use crate::err::{AnkiError, Result}; use crate::notes::NoteID; -use crate::{collection::Collection, timestamp::TimestampSecs, types::Usn, undo::Undoable}; +use crate::{ + collection::Collection, config::SchedulerVersion, timestamp::TimestampSecs, types::Usn, + undo::Undoable, +}; use num_enum::TryFromPrimitive; use serde_repr::{Deserialize_repr, Serialize_repr}; +use std::collections::HashSet; define_newtype!(CardID, i64); @@ -86,6 +90,60 @@ impl Default for Card { } } +impl Card { + pub(crate) fn return_home(&mut self, sched: SchedulerVersion) { + if self.odid.0 == 0 { + // this should not happen + return; + } + + // fixme: avoid bumping mtime? + self.did = self.odid; + self.odid.0 = 0; + if self.odue > 0 { + self.due = self.odue; + } + self.odue = 0; + + self.queue = match sched { + SchedulerVersion::V1 => { + match self.ctype { + CardType::New => CardQueue::New, + CardType::Learn => CardQueue::New, + CardType::Review => CardQueue::Review, + // not applicable in v1, should not happen + CardType::Relearn => { + println!("did not expect relearn type in v1 for card {}", self.id); + CardQueue::New + } + } + } + SchedulerVersion::V2 => { + if (self.queue as i8) >= 0 { + match self.ctype { + CardType::Learn | CardType::Relearn => { + if self.due > 1_000_000_000 { + // unix timestamp + CardQueue::Learn + } else { + // day number + CardQueue::DayLearn + } + } + CardType::New => CardQueue::New, + CardType::Review => CardQueue::Review, + } + } else { + self.queue + } + } + }; + + if sched == SchedulerVersion::V1 && self.ctype == CardType::Learn { + self.ctype = CardType::New; + } + } +} #[derive(Debug)] pub(crate) struct UpdateCardUndo(Card); @@ -145,6 +203,28 @@ impl Collection { card.usn = self.usn()?; self.storage.add_card(card) } + + /// Remove cards and any resulting orphaned notes. + /// Expects a transaction. + pub(crate) fn remove_cards_inner(&mut self, cids: &[CardID]) -> Result<()> { + let usn = self.usn()?; + let mut nids = HashSet::new(); + for cid in cids { + if let Some(card) = self.storage.get_card(*cid)? { + // fixme: undo + nids.insert(card.nid); + self.storage.remove_card(*cid)?; + self.storage.add_card_grave(*cid, usn)?; + } + } + for nid in nids { + if self.storage.note_is_orphaned(nid)? { + self.remove_note_only(nid, usn)?; + } + } + + Ok(()) + } } #[cfg(test)] diff --git a/rslib/src/config.rs b/rslib/src/config.rs index e1474e3ce..ebaa08f32 100644 --- a/rslib/src/config.rs +++ b/rslib/src/config.rs @@ -9,6 +9,7 @@ use serde::{de::DeserializeOwned, Serialize}; use serde_aux::field_attributes::deserialize_bool_from_anything; use serde_derive::Deserialize; use serde_json::json; +use serde_repr::{Deserialize_repr, Serialize_repr}; use slog::warn; pub(crate) fn schema11_config_as_string() -> String { @@ -40,6 +41,13 @@ pub(crate) enum ConfigKey { LocalOffset, CurrentNoteTypeID, NextNewCardPosition, + SchedulerVersion, +} +#[derive(PartialEq, Serialize_repr, Deserialize_repr, Clone, Copy)] +#[repr(u8)] +pub(crate) enum SchedulerVersion { + V1 = 1, + V2 = 2, } impl From for &'static str { @@ -53,6 +61,7 @@ impl From for &'static str { ConfigKey::LocalOffset => "localOffset", ConfigKey::CurrentNoteTypeID => "curModel", ConfigKey::NextNewCardPosition => "nextPos", + ConfigKey::SchedulerVersion => "schedVer", } } } @@ -142,6 +151,11 @@ impl Collection { self.set_config(ConfigKey::NextNewCardPosition, &pos.wrapping_add(1))?; Ok(pos) } + + pub(crate) fn sched_ver(&self) -> SchedulerVersion { + self.get_config_optional(ConfigKey::SchedulerVersion) + .unwrap_or(SchedulerVersion::V1) + } } #[derive(Deserialize, PartialEq, Debug)] diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index c0fe58831..03d447c5c 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -1,15 +1,41 @@ // 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::Result}; - +use crate::backend_proto as pb; +pub use crate::backend_proto::{ + deck_kind::Kind as DeckKind, Deck as DeckProto, DeckCommon, DeckKind as DeckKindProto, + FilteredDeck, FilteredSearchOrder, FilteredSearchTerm, NormalDeck, +}; +use crate::{ + card::CardID, + collection::Collection, + define_newtype, + err::{AnkiError, Result}, + text::normalize_to_nfc, + timestamp::TimestampSecs, + types::Usn, +}; mod schema11; -pub use schema11::Deck; -use std::sync::Arc; +pub use schema11::DeckSchema11; +use std::{borrow::Cow, sync::Arc}; define_newtype!(DeckID, i64); -pub(crate) fn child_ids<'a>(decks: &'a [Deck], name: &str) -> impl Iterator + 'a { +#[derive(Debug)] +pub struct Deck { + pub id: DeckID, + pub name: String, + pub mtime_secs: TimestampSecs, + pub usn: Usn, + pub common: DeckCommon, + pub kind: DeckKind, +} + +// fixme: needs update +pub(crate) fn child_ids<'a>( + decks: &'a [DeckSchema11], + name: &str, +) -> impl Iterator + 'a { let prefix = format!("{}::", name.to_ascii_lowercase()); decks .iter() @@ -17,7 +43,8 @@ pub(crate) fn child_ids<'a>(decks: &'a [Deck], name: &str) -> impl Iterator Option<&Deck> { +// fixme: needs update +pub(crate) fn get_deck(decks: &[DeckSchema11], id: DeckID) -> Option<&DeckSchema11> { for d in decks { if d.id() == id { return Some(d); @@ -28,12 +55,93 @@ pub(crate) fn get_deck(decks: &[Deck], id: DeckID) -> Option<&Deck> { } impl Deck { - pub(crate) fn is_filtered(&self) -> bool { - matches!(self, Deck::Filtered(_)) + pub fn new_normal() -> Deck { + let mut norm = NormalDeck::default(); + norm.config_id = 1; + + Deck { + id: DeckID(0), + name: "".into(), + mtime_secs: TimestampSecs(0), + usn: Usn(0), + common: DeckCommon::default(), + kind: DeckKind::Normal(norm), + } + } + + pub fn new_filtered() -> Deck { + let mut filt = FilteredDeck::default(); + filt.search_terms.push(FilteredSearchTerm { + search: "".into(), + limit: 100, + order: 0, + }); + filt.preview_delay = 10; + filt.reschedule = true; + Deck { + id: DeckID(0), + name: "".into(), + mtime_secs: TimestampSecs(0), + usn: Usn(0), + common: DeckCommon::default(), + kind: DeckKind::Filtered(filt), + } } } +impl Deck { + pub(crate) fn is_filtered(&self) -> bool { + matches!(self.kind, DeckKind::Filtered(_)) + } + + pub(crate) fn prepare_for_update(&mut self) { + // fixme - we currently only do this when converting from human; should be done in pub methods instead + + // if self.name.contains(invalid_char_for_deck_component) { + // self.name = self.name.replace(invalid_char_for_deck_component, ""); + // } + // ensure_string_in_nfc(&mut self.name); + } + + // fixme: unify with prepare for update + pub(crate) fn set_modified(&mut self, usn: Usn) { + self.mtime_secs = TimestampSecs::now(); + self.usn = usn; + } +} + +// fixme: need to bump usn on upgrade if we rename +fn invalid_char_for_deck_component(c: char) -> bool { + c.is_ascii_control() || c == '"' +} + +fn normalized_deck_name_component(comp: &str) -> Cow { + let mut out = normalize_to_nfc(comp); + if out.contains(invalid_char_for_deck_component) { + out = out.replace(invalid_char_for_deck_component, "").into(); + } + let trimmed = out.trim(); + if trimmed.is_empty() { + "blank".into() + } else if trimmed.len() != out.len() { + // fixme: trimming leading/trailing spaces may break old clients if we don't bump mod + trimmed.to_string().into() + } else { + out + } +} + +pub(crate) fn human_deck_name_to_native(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for comp in name.split("::") { + out.push_str(&normalized_deck_name_component(comp)); + out.push('\x1f'); + } + out.trim_end_matches('\x1f').into() +} + impl Collection { + // fixme: this cache may belong in CardGenContext? pub(crate) fn get_deck(&mut self, did: DeckID) -> Result>> { if let Some(deck) = self.state.deck_cache.get(&did) { return Ok(Some(deck.clone())); @@ -47,3 +155,312 @@ impl Collection { } } } + +impl From for DeckProto { + fn from(d: Deck) -> Self { + DeckProto { + id: d.id.0, + name: d.name, + mtime_secs: d.mtime_secs.0 as u32, + usn: d.usn.0, + common: Some(d.common), + kind: Some(d.kind.into()), + } + } +} + +impl From for pb::deck::Kind { + fn from(k: DeckKind) -> Self { + match k { + DeckKind::Normal(n) => pb::deck::Kind::Normal(n), + DeckKind::Filtered(f) => pb::deck::Kind::Filtered(f), + } + } +} + +fn immediate_parent_name(machine_name: &str) -> Option<&str> { + machine_name.rsplitn(2, '\x1f').nth(1) +} + +impl Collection { + pub(crate) fn add_or_update_deck(&mut self, deck: &mut Deck, preserve_usn: bool) -> Result<()> { + // fixme: vet cache clearing + self.state.deck_cache.clear(); + + self.transact(None, |col| { + let usn = col.usn()?; + + deck.prepare_for_update(); + + // fixme: bail + assert!(!deck.name.contains("::")); + + // fixme: check deck name is not duplicate + // handle blank deck name, etc + + if !preserve_usn { + deck.set_modified(usn); + } + + if deck.id.0 == 0 { + col.match_or_create_parents(deck)?; + col.storage.add_deck(deck) + } else { + if let Some(existing_deck) = col.storage.get_deck(deck.id)? { + if existing_deck.name != deck.name { + return col.update_renamed_deck(existing_deck, deck, usn); + } + } else { + // fixme: this should only happen in the syncing case, and we should + // ensure there are no missing parents at the end of the sync + } + col.storage.update_deck(deck) + } + }) + } + + pub fn get_or_create_normal_deck(&mut self, human_name: &str) -> Result { + let native_name = human_deck_name_to_native(human_name); + if let Some(did) = self.storage.get_deck_id(&native_name)? { + self.storage.get_deck(did).map(|opt| opt.unwrap()) + } else { + let mut deck = Deck::new_normal(); + deck.name = native_name; + self.add_or_update_deck(&mut deck, false)?; + Ok(deck) + } + } + + fn update_renamed_deck(&mut self, existing: Deck, updated: &mut Deck, usn: Usn) -> Result<()> { + // new name should not conflict with a different deck + if let Some(other_did) = self.storage.get_deck_id(&updated.name)? { + if other_did != updated.id { + // fixme: this could break when syncing + return Err(AnkiError::Existing); + } + } + + self.match_or_create_parents(updated)?; + self.storage.update_deck(updated)?; + self.rename_child_decks(&existing, &updated.name, usn) + } + + // fixme: make sure this handles foo::bar and FOO::baz + fn rename_child_decks(&mut self, old: &Deck, new_name: &str, usn: Usn) -> Result<()> { + let children = self.storage.child_decks(old)?; + let old_component_count = old.name.matches('\x1f').count() + 1; + + for mut child in children { + let child_components: Vec<_> = child.name.split('\x1f').collect(); + let child_only = &child_components[old_component_count..]; + let new_name = format!("{}\x1f{}", new_name, child_only.join("\x1f")); + child.name = new_name; + child.set_modified(usn); + self.storage.update_deck(&child)?; + } + + Ok(()) + } + + /// Add a single, normal deck with the provided name for a child deck. + /// Caller must have done necessarily validation on name. + fn add_parent_deck(&self, machine_name: &str) -> Result<()> { + let mut deck = Deck::new_normal(); + deck.name = machine_name.into(); + // fixme: undo + self.storage.add_deck(&mut deck) + } + + /// If parent deck(s) exist, rewrite name to match their case. + /// If they don't exist, create them. + /// Returns an error if a DB operation fails, or if the first existing parent is a filtered deck. + fn match_or_create_parents(&mut self, deck: &mut Deck) -> Result<()> { + let child_split: Vec<_> = deck.name.split('\x1f').collect(); + if let Some(parent_deck) = self.first_existing_parent(&deck.name, 0)? { + if parent_deck.is_filtered() { + return Err(AnkiError::DeckIsFiltered); + } + let parent_count = parent_deck.name.matches('\x1f').count() + 1; + let need_create = parent_count != child_split.len() - 1; + deck.name = format!( + "{}\x1f{}", + parent_deck.name, + &child_split[parent_count..].join("\x1f") + ); + if need_create { + self.create_missing_parents(&deck.name)?; + } + Ok(()) + } else if child_split.len() == 1 { + // no parents required + Ok(()) + } else { + // no existing parents + self.create_missing_parents(&deck.name) + } + } + + fn create_missing_parents(&self, mut machine_name: &str) -> Result<()> { + while let Some(parent_name) = immediate_parent_name(machine_name) { + if self.storage.get_deck_id(parent_name)?.is_none() { + self.add_parent_deck(parent_name)?; + } + machine_name = parent_name; + } + Ok(()) + } + + fn first_existing_parent( + &self, + machine_name: &str, + recursion_level: usize, + ) -> Result> { + if recursion_level > 10 { + return Err(AnkiError::invalid_input("deck nesting level too deep")); + } + if let Some(parent_name) = immediate_parent_name(machine_name) { + if let Some(parent_did) = self.storage.get_deck_id(parent_name)? { + self.storage.get_deck(parent_did) + } else { + self.first_existing_parent(parent_name, recursion_level + 1) + } + } else { + Ok(None) + } + } + + /// Get a deck based on its human name. If you have a machine name, + /// use the method in storage instead. + pub(crate) fn get_deck_id(&self, human_name: &str) -> Result> { + let machine_name = human_deck_name_to_native(&human_name); + self.storage.get_deck_id(&machine_name) + } + + pub fn remove_deck_and_child_decks(&mut self, did: DeckID) -> Result<()> { + self.transact(None, |col| { + let usn = col.usn()?; + + if let Some(deck) = col.storage.get_deck(did)? { + let child_decks = col.storage.child_decks(&deck)?; + + // top level + col.remove_single_deck(&deck, usn)?; + + // remove children + for deck in child_decks { + col.remove_single_deck(&deck, usn)?; + } + } + Ok(()) + }) + } + + pub(crate) fn remove_single_deck(&mut self, deck: &Deck, usn: Usn) -> Result<()> { + // fixme: undo + match deck.kind { + DeckKind::Normal(_) => self.delete_all_cards_in_normal_deck(deck.id)?, + DeckKind::Filtered(_) => self.return_all_cards_in_filtered_deck(deck.id)?, + } + self.storage.remove_deck(deck.id)?; + self.storage.add_deck_grave(deck.id, usn) + } + + fn delete_all_cards_in_normal_deck(&mut self, did: DeckID) -> Result<()> { + // fixme: need to search on odid as well + // fixme: the odid requirement will require a table scan, which will be slow when deleting a large tree + let cids = self.storage.all_cards_in_single_deck(did)?; + self.remove_cards_inner(&cids) + } + + fn return_all_cards_in_filtered_deck(&mut self, did: DeckID) -> Result<()> { + let cids = self.storage.all_cards_in_single_deck(did)?; + self.return_cards_to_home_deck(&cids) + } + + fn return_cards_to_home_deck(&mut self, cids: &[CardID]) -> Result<()> { + let sched = self.sched_ver(); + for cid in cids { + if let Some(mut card) = self.storage.get_card(*cid)? { + // fixme: undo + card.return_home(sched); + self.storage.update_card(&card)?; + } + } + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::{human_deck_name_to_native, immediate_parent_name}; + use crate::{ + collection::{open_test_collection, Collection}, + err::Result, + }; + + fn sorted_names(col: &Collection) -> Vec { + col.storage + .get_all_deck_names() + .unwrap() + .into_iter() + .map(|d| d.1) + .collect() + } + + #[test] + fn parent() { + assert_eq!(immediate_parent_name("foo"), None); + assert_eq!(immediate_parent_name("foo\x1fbar"), Some("foo")); + assert_eq!( + immediate_parent_name("foo\x1fbar\x1fbaz"), + Some("foo\x1fbar") + ); + } + + #[test] + fn from_human() { + assert_eq!(&human_deck_name_to_native("foo"), "foo"); + assert_eq!(&human_deck_name_to_native("foo::bar"), "foo\x1fbar"); + assert_eq!(&human_deck_name_to_native("fo\x1fo::ba\nr"), "foo\x1fbar"); + assert_eq!( + &human_deck_name_to_native("foo::::baz"), + "foo\x1fblank\x1fbaz" + ); + } + + #[test] + fn adding_updating() -> Result<()> { + let mut col = open_test_collection(); + + let deck1 = col.get_or_create_normal_deck("foo")?; + let deck2 = col.get_or_create_normal_deck("FOO")?; + assert_eq!(deck1.id, deck2.id); + assert_eq!(sorted_names(&col), vec!["Default", "foo"]); + + // missing parents should be automatically created, and case should match + // existing parents + let _deck3 = col.get_or_create_normal_deck("FOO::BAR::BAZ")?; + assert_eq!( + sorted_names(&col), + vec!["Default", "foo", "foo::BAR", "foo::BAR::BAZ"] + ); + + Ok(()) + } + + #[test] + fn renaming() -> Result<()> { + let mut col = open_test_collection(); + + let _ = col.get_or_create_normal_deck("foo::bar::baz")?; + let mut top_deck = col.get_or_create_normal_deck("foo")?; + top_deck.name = "other".into(); + col.add_or_update_deck(&mut top_deck, false)?; + assert_eq!( + sorted_names(&col), + vec!["Default", "other", "other::bar", "other::bar::baz"] + ); + + Ok(()) + } +} diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index ee9608df6..48a41b76d 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -2,11 +2,16 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::DeckID; +use super::{ + human_deck_name_to_native, Deck, DeckCommon, DeckKind, FilteredDeck, FilteredSearchTerm, + NormalDeck, +}; use crate::{ serde::{default_on_invalid, deserialize_bool_from_anything, deserialize_number_from_string}, timestamp::TimestampSecs, types::Usn, }; + use serde_derive::{Deserialize, Serialize}; use serde_json::Value; use serde_tuple::Serialize_tuple; @@ -14,19 +19,19 @@ use std::collections::HashMap; #[derive(Serialize, PartialEq, Debug, Clone)] #[serde(untagged)] -pub enum Deck { - Normal(NormalDeck), - Filtered(FilteredDeck), +pub enum DeckSchema11 { + Normal(NormalDeckSchema11), + Filtered(FilteredDeckSchema11), } // serde doesn't support integer/bool enum tags, so we manually pick the correct variant mod dynfix { - use super::{Deck, FilteredDeck, NormalDeck}; + use super::{DeckSchema11, FilteredDeckSchema11, NormalDeckSchema11}; use serde::de::{self, Deserialize, Deserializer}; use serde_json::{Map, Value}; - impl<'de> Deserialize<'de> for Deck { - fn deserialize(deserializer: D) -> Result + impl<'de> Deserialize<'de> for DeckSchema11 { + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { @@ -58,12 +63,12 @@ mod dynfix { let rest = Value::Object(map); if is_dyn { - FilteredDeck::deserialize(rest) - .map(Deck::Filtered) + FilteredDeckSchema11::deserialize(rest) + .map(DeckSchema11::Filtered) .map_err(de::Error::custom) } else { - NormalDeck::deserialize(rest) - .map(Deck::Normal) + NormalDeckSchema11::deserialize(rest) + .map(DeckSchema11::Normal) .map_err(de::Error::custom) } } @@ -71,7 +76,7 @@ mod dynfix { } #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] -pub struct DeckCommon { +pub struct DeckCommonSchema11 { #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) id: DeckID, #[serde( @@ -83,7 +88,7 @@ pub struct DeckCommon { pub(crate) name: String, pub(crate) usn: Usn, #[serde(flatten)] - pub(crate) today: DeckToday, + pub(crate) today: DeckTodaySchema11, collapsed: bool, #[serde(default)] desc: String, @@ -95,9 +100,9 @@ pub struct DeckCommon { #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct NormalDeck { +pub struct NormalDeckSchema11 { #[serde(flatten)] - pub(crate) common: DeckCommon, + pub(crate) common: DeckCommonSchema11, #[serde(deserialize_with = "deserialize_number_from_string")] pub(crate) conf: i64, @@ -109,13 +114,13 @@ pub struct NormalDeck { #[derive(Serialize, Deserialize, PartialEq, Debug, Clone)] #[serde(rename_all = "camelCase")] -pub struct FilteredDeck { +pub struct FilteredDeckSchema11 { #[serde(flatten)] - common: DeckCommon, + common: DeckCommonSchema11, #[serde(deserialize_with = "deserialize_bool_from_anything")] resched: bool, - terms: Vec, + terms: Vec, // unused, but older clients require its existence #[serde(default)] @@ -127,50 +132,50 @@ pub struct FilteredDeck { // new scheduler #[serde(default)] - preview_delay: u16, + preview_delay: u32, } #[derive(Serialize, Deserialize, Debug, PartialEq, Default, Clone)] -pub struct DeckToday { +pub struct DeckTodaySchema11 { #[serde(rename = "lrnToday")] - pub(crate) lrn: TodayAmount, + pub(crate) lrn: TodayAmountSchema11, #[serde(rename = "revToday")] - pub(crate) rev: TodayAmount, + pub(crate) rev: TodayAmountSchema11, #[serde(rename = "newToday")] - pub(crate) new: TodayAmount, + pub(crate) new: TodayAmountSchema11, #[serde(rename = "timeToday")] - pub(crate) time: TodayAmount, + pub(crate) time: TodayAmountSchema11, } #[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Default, Clone)] #[serde(from = "Vec")] -pub struct TodayAmount { +pub struct TodayAmountSchema11 { day: i32, amount: i32, } -impl From> for TodayAmount { +impl From> for TodayAmountSchema11 { fn from(mut v: Vec) -> Self { let amt = v.pop().and_then(|v| v.as_i64()).unwrap_or(0); let day = v.pop().and_then(|v| v.as_i64()).unwrap_or(0); - TodayAmount { + TodayAmountSchema11 { amount: amt as i32, day: day as i32, } } } #[derive(Serialize_tuple, Deserialize, Debug, PartialEq, Clone)] -pub struct FilteredSearch { +pub struct FilteredSearchTermSchema11 { search: String, #[serde(deserialize_with = "deserialize_number_from_string")] limit: i32, - order: i8, + order: i32, } -impl Deck { - pub fn common(&self) -> &DeckCommon { +impl DeckSchema11 { + pub fn common(&self) -> &DeckCommonSchema11 { match self { - Deck::Normal(d) => &d.common, - Deck::Filtered(d) => &d.common, + DeckSchema11::Normal(d) => &d.common, + DeckSchema11::Filtered(d) => &d.common, } } @@ -190,16 +195,16 @@ impl Deck { } } -impl Default for Deck { +impl Default for DeckSchema11 { fn default() -> Self { - Deck::Normal(NormalDeck::default()) + DeckSchema11::Normal(NormalDeckSchema11::default()) } } -impl Default for NormalDeck { +impl Default for NormalDeckSchema11 { fn default() -> Self { - NormalDeck { - common: DeckCommon { + NormalDeckSchema11 { + common: DeckCommonSchema11 { id: DeckID(0), mtime: TimestampSecs(0), name: "".to_string(), @@ -216,3 +221,169 @@ impl Default for NormalDeck { } } } + +// schema 11 -> latest + +impl From for Deck { + fn from(deck: DeckSchema11) -> Self { + match deck { + DeckSchema11::Normal(d) => Deck { + id: d.common.id, + name: human_deck_name_to_native(&d.common.name), + mtime_secs: d.common.mtime, + usn: d.common.usn, + common: (&d.common).into(), + kind: DeckKind::Normal(d.into()), + }, + DeckSchema11::Filtered(d) => Deck { + id: d.common.id, + name: human_deck_name_to_native(&d.common.name), + mtime_secs: d.common.mtime, + usn: d.common.usn, + common: (&d.common).into(), + kind: DeckKind::Filtered(d.into()), + }, + } + } +} + +impl From<&DeckCommonSchema11> for DeckCommon { + fn from(common: &DeckCommonSchema11) -> Self { + let other = if common.other.is_empty() { + vec![] + } else { + serde_json::to_vec(&common.other).unwrap_or_default() + }; + DeckCommon { + collapsed: common.collapsed, + last_day_studied: common.today.new.day as u32, + new_studied: common.today.new.amount, + review_studied: common.today.rev.amount, + learning_studied: common.today.lrn.amount, + secs_studied: common.today.time.amount, + other, + } + } +} + +impl From for NormalDeck { + fn from(deck: NormalDeckSchema11) -> Self { + NormalDeck { + config_id: deck.conf, + extend_new: deck.extend_new.max(0) as u32, + extend_review: deck.extend_rev.max(0) as u32, + description: deck.common.desc, + } + } +} + +impl From for FilteredDeck { + fn from(deck: FilteredDeckSchema11) -> Self { + FilteredDeck { + reschedule: deck.resched, + search_terms: deck.terms.into_iter().map(Into::into).collect(), + delays: deck.delays.unwrap_or_default(), + preview_delay: deck.preview_delay, + } + } +} + +impl From for FilteredSearchTerm { + fn from(term: FilteredSearchTermSchema11) -> Self { + FilteredSearchTerm { + search: term.search, + limit: term.limit.max(0) as u32, + order: term.order, + } + } +} + +// latest -> schema 11 + +impl From for DeckSchema11 { + fn from(deck: Deck) -> Self { + match deck.kind { + DeckKind::Normal(ref norm) => DeckSchema11::Normal(NormalDeckSchema11 { + conf: norm.config_id, + extend_new: norm.extend_new as i32, + extend_rev: norm.extend_review as i32, + common: deck.into(), + }), + DeckKind::Filtered(ref filt) => DeckSchema11::Filtered(FilteredDeckSchema11 { + resched: filt.reschedule, + terms: filt.search_terms.iter().map(|v| v.clone().into()).collect(), + separate: true, + delays: if filt.delays.is_empty() { + None + } else { + Some(filt.delays.clone()) + }, + preview_delay: filt.preview_delay as u32, + common: deck.into(), + }), + } + } +} + +impl From for DeckCommonSchema11 { + fn from(deck: Deck) -> Self { + let other: HashMap = if deck.common.other.is_empty() { + Default::default() + } else { + serde_json::from_slice(&deck.common.other).unwrap_or_default() + }; + DeckCommonSchema11 { + id: deck.id, + mtime: deck.mtime_secs, + name: deck.name.replace("\x1f", "::"), + usn: deck.usn, + today: (&deck).into(), + collapsed: deck.common.collapsed, + dynamic: if matches!(deck.kind, DeckKind::Filtered(_)) { + 1 + } else { + 0 + }, + desc: match deck.kind { + DeckKind::Normal(n) => n.description, + DeckKind::Filtered(_) => String::new(), + }, + other, + } + } +} + +impl From<&Deck> for DeckTodaySchema11 { + fn from(deck: &Deck) -> Self { + let day = deck.common.last_day_studied as i32; + let c = &deck.common; + DeckTodaySchema11 { + lrn: TodayAmountSchema11 { + day, + amount: c.learning_studied, + }, + rev: TodayAmountSchema11 { + day, + amount: c.review_studied, + }, + new: TodayAmountSchema11 { + day, + amount: c.new_studied, + }, + time: TodayAmountSchema11 { + day, + amount: c.secs_studied, + }, + } + } +} + +impl From for FilteredSearchTermSchema11 { + fn from(term: FilteredSearchTerm) -> Self { + FilteredSearchTermSchema11 { + search: term.search, + limit: term.limit as i32, + order: term.order, + } + } +} diff --git a/rslib/src/err.rs b/rslib/src/err.rs index 5c3f2a74f..32389afc7 100644 --- a/rslib/src/err.rs +++ b/rslib/src/err.rs @@ -48,6 +48,12 @@ pub enum AnkiError { #[fail(display = "A requested item was not found.")] NotFound, + + #[fail(display = "The provided item already exists.")] + Existing, + + #[fail(display = "Unable to place item in/under a filtered deck.")] + DeckIsFiltered, } // error helpers diff --git a/rslib/src/notes.rs b/rslib/src/notes.rs index dbfc78560..f80592a46 100644 --- a/rslib/src/notes.rs +++ b/rslib/src/notes.rs @@ -220,6 +220,16 @@ impl Collection { Ok(()) } + + /// Remove a note. Cards must already have been deleted. + pub(crate) fn remove_note_only(&mut self, nid: NoteID, usn: Usn) -> Result<()> { + if let Some(_note) = self.storage.get_note(nid)? { + // fixme: undo + self.storage.remove_note(nid)?; + self.storage.add_note_grave(nid, usn)?; + } + Ok(()) + } } #[cfg(test)] diff --git a/rslib/src/notetype/cardgen.rs b/rslib/src/notetype/cardgen.rs index 900c69b0c..2bd28ca24 100644 --- a/rslib/src/notetype/cardgen.rs +++ b/rslib/src/notetype/cardgen.rs @@ -289,11 +289,8 @@ impl Collection { /// If deck exists and and is a normal deck, return it. fn deck_id_if_normal(&mut self, did: DeckID) -> Option { - // fixme: currently disabled until deck writes are immediate - return Some(did); - self.get_deck(did) .ok() - .and_then(|opt| opt.and_then(|d| if !d.is_filtered() { Some(d.id()) } else { None })) + .and_then(|opt| opt.and_then(|d| if !d.is_filtered() { Some(d.id) } else { None })) } } diff --git a/rslib/src/search/cards.rs b/rslib/src/search/cards.rs index 0e6346548..5bc3a131d 100644 --- a/rslib/src/search/cards.rs +++ b/rslib/src/search/cards.rs @@ -92,8 +92,7 @@ fn write_order(sql: &mut String, kind: &SortKind, reverse: bool) -> Result<()> { Ok(()) } -// In the future these items should be moved from JSON into separate SQL tables, -// - for now we use a temporary deck to sort them. +// fixme: use the new tables fn prepare_sort(col: &mut Collection, kind: &SortKind) -> Result<()> { use SortKind::*; match kind { @@ -106,7 +105,7 @@ fn prepare_sort(col: &mut Collection, kind: &SortKind) -> Result<()> { match kind { CardDeck => { - for (k, v) in col.storage.get_all_decks()? { + for (k, v) in col.storage.get_all_decks_as_schema11()? { stmt.execute(params![k, v.name()])?; } } diff --git a/rslib/src/search/sqlwriter.rs b/rslib/src/search/sqlwriter.rs index 17a491051..1cb8baf39 100644 --- a/rslib/src/search/sqlwriter.rs +++ b/rslib/src/search/sqlwriter.rs @@ -221,6 +221,7 @@ impl SqlWriter<'_> { Ok(()) } + // fixme: update for new table fn write_deck(&mut self, deck: &str) -> Result<()> { match deck { "*" => write!(self.sql, "true").unwrap(), @@ -229,7 +230,7 @@ impl SqlWriter<'_> { let all_decks: Vec<_> = self .col .storage - .get_all_decks()? + .get_all_decks_as_schema11()? .into_iter() .map(|(_, v)| v) .collect(); diff --git a/rslib/src/storage/card/mod.rs b/rslib/src/storage/card/mod.rs index 16a060709..3631f16e8 100644 --- a/rslib/src/storage/card/mod.rs +++ b/rslib/src/storage/card/mod.rs @@ -111,6 +111,13 @@ impl super::SqliteStorage { card.id = CardID(self.db.last_insert_rowid()); Ok(()) } + + pub(crate) fn remove_card(&self, cid: CardID) -> Result<()> { + self.db + .prepare_cached("delete from cards where id = ?")? + .execute(&[cid])?; + Ok(()) + } } #[cfg(test)] diff --git a/rslib/src/storage/deck/add_deck.sql b/rslib/src/storage/deck/add_deck.sql new file mode 100644 index 000000000..fbee07b1f --- /dev/null +++ b/rslib/src/storage/deck/add_deck.sql @@ -0,0 +1 @@ +insert into decks \ No newline at end of file diff --git a/rslib/src/storage/deck/alloc_id.sql b/rslib/src/storage/deck/alloc_id.sql new file mode 100644 index 000000000..80004ad56 --- /dev/null +++ b/rslib/src/storage/deck/alloc_id.sql @@ -0,0 +1,13 @@ +select + case + when ?1 in ( + select + id + from decks + ) then ( + select + max(id) + 1 + from decks + ) + else ?1 + end; \ No newline at end of file diff --git a/rslib/src/storage/deck/get_deck.sql b/rslib/src/storage/deck/get_deck.sql new file mode 100644 index 000000000..51ec1c145 --- /dev/null +++ b/rslib/src/storage/deck/get_deck.sql @@ -0,0 +1,8 @@ +select + name, + id, + mtime_secs, + usn, + common, + kind +from decks \ No newline at end of file diff --git a/rslib/src/storage/deck/mod.rs b/rslib/src/storage/deck/mod.rs index 7e3f88a8c..11c49ce7e 100644 --- a/rslib/src/storage/deck/mod.rs +++ b/rslib/src/storage/deck/mod.rs @@ -3,29 +3,200 @@ use super::SqliteStorage; use crate::{ - decks::{Deck, DeckID}, - err::Result, + card::CardID, + decks::{Deck, DeckCommon, DeckID, DeckKindProto, DeckSchema11}, + err::{AnkiError, DBErrorKind, Result}, + i18n::{I18n, TR}, + timestamp::TimestampMillis, }; -use rusqlite::NO_PARAMS; -use std::collections::HashMap; +use prost::Message; +use rusqlite::{params, Row, NO_PARAMS}; +use std::collections::{HashMap, HashSet}; +use unicase::UniCase; + +fn row_to_deck(row: &Row) -> Result { + let common = DeckCommon::decode(row.get_raw(4).as_blob()?)?; + let kind = DeckKindProto::decode(row.get_raw(5).as_blob()?)?; + let id = row.get(1)?; + Ok(Deck { + id, + name: row.get(0)?, + mtime_secs: row.get(2)?, + usn: row.get(3)?, + common, + kind: kind.kind.ok_or_else(|| AnkiError::DBError { + kind: DBErrorKind::MissingEntity, + info: format!("invalid deck kind: {}", id), + })?, + }) +} impl SqliteStorage { - pub(crate) fn get_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()?)?) - }) + pub(crate) fn get_all_decks_as_schema11(&self) -> Result> { + self.get_all_decks() + .map(|r| r.into_iter().map(|d| (d.id, d.into())).collect()) } - pub(crate) fn set_all_decks(&self, decks: HashMap) -> Result<()> { + pub(crate) fn get_deck(&self, did: DeckID) -> Result> { + self.db + .prepare_cached(concat!(include_str!("get_deck.sql"), " where id = ?"))? + .query_and_then(&[did], row_to_deck)? + .next() + .transpose() + } + + pub(crate) fn get_all_decks(&self) -> Result> { + self.db + .prepare(include_str!("get_deck.sql"))? + .query_and_then(NO_PARAMS, row_to_deck)? + .collect() + } + + /// Get all deck names in human-readable form (::) + pub(crate) fn get_all_deck_names(&self) -> Result> { + self.db + .prepare("select name, id from decks")? + .query_and_then(NO_PARAMS, |row| { + Ok((row.get(1)?, row.get_raw(0).as_str()?.replace('\x1f', "::"))) + })? + .collect() + } + + pub(crate) fn get_deck_id(&self, machine_name: &str) -> Result> { + self.db + .prepare("select id from decks where name = ?")? + .query_and_then(&[machine_name], |row| row.get(0))? + .next() + .transpose() + .map_err(Into::into) + } + + // caller should ensure name unique + pub(crate) fn add_deck(&self, deck: &mut Deck) -> Result<()> { + assert!(deck.id.0 == 0); + deck.id.0 = self + .db + .prepare(include_str!("alloc_id.sql"))? + .query_row(&[TimestampMillis::now()], |r| r.get(0))?; + self.update_deck(deck).map_err(|err| { + // restore id of 0 + deck.id.0 = 0; + err + }) + } + + // fixme: bail instead of assert + pub(crate) fn update_deck(&self, deck: &Deck) -> Result<()> { + assert!(deck.id.0 != 0); + let mut stmt = self.db.prepare_cached(include_str!("update_deck.sql"))?; + let mut common = vec![]; + deck.common.encode(&mut common)?; + let kind_enum = DeckKindProto { + kind: Some(deck.kind.clone()), + }; + let mut kind = vec![]; + kind_enum.encode(&mut kind)?; + stmt.execute(params![ + deck.name, + deck.id, + deck.mtime_secs, + deck.usn, + common, + kind + ])?; + + Ok(()) + } + + pub(crate) fn remove_deck(&self, did: DeckID) -> Result<()> { + self.db + .prepare_cached("delete from decks where id = ?")? + .execute(&[did])?; + Ok(()) + } + + pub(crate) fn all_cards_in_single_deck(&self, did: DeckID) -> Result> { + self.db + .prepare_cached("select id from cards where did = ?")? + .query_and_then(&[did], |r| r.get(0).map_err(Into::into))? + .collect() + } + + pub(crate) fn child_decks(&self, parent: &Deck) -> Result> { + let prefix_start = format!("{}\x1f", parent.name); + let prefix_end = format!("{}\x20", parent.name); + self.db + .prepare_cached(concat!( + include_str!("get_deck.sql"), + " where name >= ? and name < ?" + ))? + .query_and_then(&[prefix_start, prefix_end], row_to_deck)? + .collect() + } + + // Upgrading/downgrading/legacy + + // pub(crate) fn get_all_decks_as_schema11(&self) -> Result> { + // let mut nts = HashMap::new(); + // for (ntid, _name) in self.get_all_deck_names()? { + // let full = self.get_deck(ntid)?.unwrap(); + // nts.insert(ntid, full.into()); + // } + // Ok(nts) + // } + + pub(super) fn add_default_deck(&self, i18n: &I18n) -> Result<()> { + let mut deck = Deck::new_normal(); + deck.id.0 = 1; + // fixme: separate key + deck.name = i18n.tr(TR::DeckConfigDefaultName).into(); + self.update_deck(&deck) + } + + // fixme: make sure conflicting deck names don't break things + pub(crate) fn upgrade_decks_to_schema15(&self) -> Result<()> { + let decks = self.get_schema11_decks()?; + let mut names = HashSet::new(); + for (_id, deck) in decks { + let mut deck = Deck::from(deck); + loop { + let name = UniCase::new(deck.name.clone()); + if !names.contains(&name) { + names.insert(name); + break; + } + deck.name.push('_'); + } + self.update_deck(&deck)?; + } + self.db.execute("update col set decks = ''", NO_PARAMS)?; + Ok(()) + } + + pub(crate) fn downgrade_decks_from_schema15(&self) -> Result<()> { + let decks = self.get_all_decks_as_schema11()?; + self.set_schema11_decks(decks) + } + + fn get_schema11_decks(&self) -> Result> { + let mut stmt = self.db.prepare("select decks from col")?; + let decks = stmt + .query_and_then(NO_PARAMS, |row| -> Result> { + let v: HashMap = + serde_json::from_str(row.get_raw(0).as_str()?)?; + Ok(v) + })? + .next() + .ok_or_else(|| AnkiError::DBError { + info: "col table empty".to_string(), + kind: DBErrorKind::MissingEntity, + })??; + Ok(decks) + } + + pub(crate) fn set_schema11_decks(&self, decks: HashMap) -> Result<()> { let json = serde_json::to_string(&decks)?; self.db.execute("update col set decks = ?", &[json])?; Ok(()) } - - pub(crate) fn get_deck(&self, did: DeckID) -> Result> { - // fixme: this is just temporary until we create an extra table - let mut decks = self.get_all_decks()?; - Ok(decks.remove(&did)) - } } diff --git a/rslib/src/storage/deck/update_deck.sql b/rslib/src/storage/deck/update_deck.sql new file mode 100644 index 000000000..5c77a89a2 --- /dev/null +++ b/rslib/src/storage/deck/update_deck.sql @@ -0,0 +1,4 @@ +insert + or replace into decks (name, id, mtime_secs, usn, common, kind) +values + (?, ?, ?, ?, ?, ?) \ No newline at end of file diff --git a/rslib/src/storage/graves/add.sql b/rslib/src/storage/graves/add.sql new file mode 100644 index 000000000..d01f13c95 --- /dev/null +++ b/rslib/src/storage/graves/add.sql @@ -0,0 +1,4 @@ +insert + or ignore into graves (usn, oid, type) +values + (?, ?, ?) \ No newline at end of file diff --git a/rslib/src/storage/graves/mod.rs b/rslib/src/storage/graves/mod.rs new file mode 100644 index 000000000..b55fc7155 --- /dev/null +++ b/rslib/src/storage/graves/mod.rs @@ -0,0 +1,39 @@ +// 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::{card::CardID, decks::DeckID, err::Result, notes::NoteID, types::Usn}; +use rusqlite::{params, NO_PARAMS}; + +enum GraveKind { + Card, + Note, + Deck, +} + +impl SqliteStorage { + fn add_grave(&self, oid: i64, kind: GraveKind, usn: Usn) -> Result<()> { + self.db + .prepare_cached(include_str!("add.sql"))? + .execute(params![usn, oid, kind as u8])?; + Ok(()) + } + + #[allow(dead_code)] + pub(crate) fn clear_all_graves(&self) -> Result<()> { + self.db.execute("delete from graves", NO_PARAMS)?; + Ok(()) + } + + pub(crate) fn add_card_grave(&self, cid: CardID, usn: Usn) -> Result<()> { + self.add_grave(cid.0, GraveKind::Card, usn) + } + + pub(crate) fn add_note_grave(&self, nid: NoteID, usn: Usn) -> Result<()> { + self.add_grave(nid.0, GraveKind::Note, usn) + } + + pub(crate) fn add_deck_grave(&self, did: DeckID, usn: Usn) -> Result<()> { + self.add_grave(did.0, GraveKind::Deck, usn) + } +} diff --git a/rslib/src/storage/mod.rs b/rslib/src/storage/mod.rs index 2192be013..ce2fb8df6 100644 --- a/rslib/src/storage/mod.rs +++ b/rslib/src/storage/mod.rs @@ -5,6 +5,7 @@ mod card; mod config; mod deck; mod deckconf; +mod graves; mod note; mod notetype; mod sqlite; diff --git a/rslib/src/storage/note/is_orphaned.sql b/rslib/src/storage/note/is_orphaned.sql new file mode 100644 index 000000000..7b27c1ee9 --- /dev/null +++ b/rslib/src/storage/note/is_orphaned.sql @@ -0,0 +1,5 @@ +select + count(id) = 0 +from cards +where + nid = ?; \ No newline at end of file diff --git a/rslib/src/storage/note/mod.rs b/rslib/src/storage/note/mod.rs index 9faf7e80a..da4f86e08 100644 --- a/rslib/src/storage/note/mod.rs +++ b/rslib/src/storage/note/mod.rs @@ -74,4 +74,18 @@ impl super::SqliteStorage { note.id.0 = self.db.last_insert_rowid(); Ok(()) } + + pub(crate) fn remove_note(&self, nid: NoteID) -> Result<()> { + self.db + .prepare_cached("delete from notes where id = ?")? + .execute(&[nid])?; + Ok(()) + } + + pub(crate) fn note_is_orphaned(&self, nid: NoteID) -> Result { + self.db + .prepare_cached(include_str!("is_orphaned.sql"))? + .query_row(&[nid], |r| r.get(0)) + .map_err(Into::into) + } } diff --git a/rslib/src/storage/sqlite.rs b/rslib/src/storage/sqlite.rs index 00307723f..761ccc589 100644 --- a/rslib/src/storage/sqlite.rs +++ b/rslib/src/storage/sqlite.rs @@ -198,6 +198,7 @@ impl SqliteStorage { if create { storage.add_default_deck_config(i18n)?; + storage.add_default_deck(i18n)?; storage.add_stock_notetypes(i18n)?; } diff --git a/rslib/src/storage/upgrades/mod.rs b/rslib/src/storage/upgrades/mod.rs index bc19e5c60..f8e816f22 100644 --- a/rslib/src/storage/upgrades/mod.rs +++ b/rslib/src/storage/upgrades/mod.rs @@ -17,6 +17,7 @@ impl SqliteStorage { self.db .execute_batch(include_str!("schema15_upgrade.sql"))?; self.upgrade_notetypes_to_schema15()?; + self.upgrade_decks_to_schema15()?; } Ok(()) @@ -25,6 +26,7 @@ impl SqliteStorage { pub(super) fn downgrade_to_schema_11(&self) -> Result<()> { self.begin_trx()?; + self.downgrade_decks_from_schema15()?; self.downgrade_notetypes_from_schema15()?; self.downgrade_config_from_schema14()?; self.downgrade_tags_from_schema14()?; diff --git a/rslib/src/storage/upgrades/schema11_downgrade.sql b/rslib/src/storage/upgrades/schema11_downgrade.sql index c86df17cd..14bacf26c 100644 --- a/rslib/src/storage/upgrades/schema11_downgrade.sql +++ b/rslib/src/storage/upgrades/schema11_downgrade.sql @@ -4,6 +4,7 @@ drop table tags; drop table fields; drop table templates; drop table notetypes; +drop table decks; update col set ver = 11; \ No newline at end of file diff --git a/rslib/src/storage/upgrades/schema15_upgrade.sql b/rslib/src/storage/upgrades/schema15_upgrade.sql index 9f7716934..c8bb2ce0d 100644 --- a/rslib/src/storage/upgrades/schema15_upgrade.sql +++ b/rslib/src/storage/upgrades/schema15_upgrade.sql @@ -26,6 +26,15 @@ create table notetypes ( ); create unique index idx_notetypes_name on notetypes (name); create index idx_notetypes_usn on notetypes (usn); +create table decks ( + name text not null primary key collate unicase, + id integer not null, + mtime_secs integer not null, + usn integer not null, + common bytes not null, + kind bytes not null +); +create unique index idx_decks_id on decks (id); update col set ver = 15;