handle default deck and filtered deck suppression in the backend

This commit is contained in:
Damien Elmes 2020-05-15 21:21:10 +10:00
parent 769bf04f75
commit 964a69e54e
15 changed files with 131 additions and 138 deletions

View file

@ -84,7 +84,7 @@ message BackendInput {
Empty get_empty_cards = 70;
int64 get_deck_legacy = 71;
string get_deck_id_by_name = 72;
Empty get_deck_names = 73;
GetDeckNamesIn get_deck_names = 73;
AddOrUpdateDeckLegacyIn add_or_update_deck_legacy = 74;
bool new_deck_legacy = 75;
int64 remove_deck = 76;
@ -784,3 +784,9 @@ message Preferences {
message ClozeNumbersInNoteOut {
repeated uint32 numbers = 1;
}
message GetDeckNamesIn {
bool skip_empty_default = 1;
// if unset, implies skip_empty_default
bool include_filtered = 2;
}

View file

@ -4,8 +4,7 @@
from __future__ import annotations
import copy
import unicodedata
from typing import Any, Dict, List, Optional, Tuple, Union
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
import anki # pylint: disable=unused-import
import anki.backend_pb2 as pb
@ -186,17 +185,13 @@ class DeckManager:
if did in self.active():
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."
if dyn:
return [x["name"] for x in self.all(force_default=force_default)]
else:
return [
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 all_names_and_ids(
self, skip_empty_default=False, include_filtered=True
) -> Sequence[pb.DeckNameID]:
"A sorted sequence of deck names and IDs."
return self.col.backend.get_deck_names_and_ids(
skip_empty_default, include_filtered
)
def id_for_name(self, name: str) -> Optional[int]:
return self.col.backend.get_deck_id_by_name(name)
@ -221,29 +216,28 @@ class DeckManager:
def deck_tree(self) -> pb.DeckTreeNode:
return self.col.backend.deck_tree(include_counts=False)
def all(self, force_default: bool = True) -> List:
"""A list of all decks.
list contains default deck if either:
* force_default is True
* there are no other deck
* default deck contains a card
* default deck has a child (assumed not to be the case if assume_no_child)
"""
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 all(self) -> List:
"All decks. Expensive; prefer all_names_and_ids()"
return self.get_all_legacy()
def allIds(self) -> List[str]:
print("decks.allIds() is deprecated, use .all_names_and_ids()")
return [str(x.id) for x in self.all_names_and_ids()]
def allNames(self, dyn: bool = True, force_default: bool = True) -> List:
print("decks.allNames() is deprecated, use .all_names_and_ids()")
return [
x.name
for x in self.all_names_and_ids(
skip_empty_default=not force_default, include_filtered=dyn
)
]
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)
@ -504,57 +498,6 @@ 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:
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 checkIntegrity(self) -> None:
self._recoverOrphans()
def should_deck_be_displayed(
self, deck, force_default: bool = True, assume_no_child: bool = False
) -> bool:
"""Whether the deck should appear in main window, browser side list, filter, deck selection...
True, except for empty default deck without children"""
if deck["id"] != "1":
return True
return self.should_default_be_displayed(force_default, assume_no_child)
def should_default_be_displayed(
self,
force_default: bool = True,
assume_no_child: bool = False,
default_deck: Optional[Dict[str, Any]] = None,
) -> bool:
"""Whether the default deck should appear in main window, browser side list, filter, deck selection...
True, except for empty default deck (without children)"""
if force_default:
return True
if self.col.db.scalar("select 1 from cards where did = 1 limit 1"):
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
#############################################################
@ -673,11 +616,3 @@ class DeckManager:
def isDyn(self, did: Union[int, str]) -> Any:
return self.get(did)["dyn"]
@staticmethod
def normalizeName(name: str) -> str:
return unicodedata.normalize("NFC", name.lower())
@staticmethod
def equalName(name1: str, name2: str) -> bool:
return DeckManager.normalizeName(name1) == DeckManager.normalizeName(name2)

View file

@ -686,12 +686,17 @@ class RustBackend:
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 get_deck_names_and_ids(
self, skip_empty_default: bool, include_filtered: bool = True
) -> Sequence[pb.DeckNameID]:
return self._run_command(
pb.BackendInput(
get_deck_names=pb.GetDeckNamesIn(
skip_empty_default=skip_empty_default,
include_filtered=include_filtered,
)
)
).get_deck_names.entries
def add_or_update_deck_legacy(
self, deck: Dict[str, Any], preserve_usn: bool

View file

@ -395,7 +395,9 @@ limit %d"""
else:
# benchmarks indicate it's about 10x faster to search all decks
# with the index than scan the table
extra = " and did in " + ids2str(self.col.decks.allIds())
extra = " and did in " + ids2str(
d.id for d in self.col.decks.all_names_and_ids()
)
# review cards in relearning
self.col.db.execute(
f"""

View file

@ -65,9 +65,10 @@ def test_rename():
# should be able to rename into a completely different branch, creating
# parents as necessary
d.decks.rename(d.decks.get(id), "foo::bar")
assert "foo" in d.decks.allNames()
assert "foo::bar" in d.decks.allNames()
assert "hello::world" not in d.decks.allNames()
names = [n.name for n in d.decks.all_names_and_ids()]
assert "foo" in names
assert "foo::bar" in names
assert "hello::world" not in names
# create another deck
id = d.decks.id("tmp")
# we can't rename it if it conflicts
@ -76,8 +77,9 @@ def test_rename():
d.decks.id("one::two::three")
id = d.decks.id("one")
d.decks.rename(d.decks.get(id), "yo")
names = [n.name for n in d.decks.all_names_and_ids()]
for n in "yo", "yo::two", "yo::two::three":
assert n in d.decks.allNames()
assert n in names
# over filtered
filteredId = d.decks.newDyn("filtered")
filtered = d.decks.get(filteredId)
@ -96,7 +98,7 @@ def test_renameForDragAndDrop():
d = getEmptyCol()
def deckNames():
return [name for name in sorted(d.decks.allNames()) if name != "Default"]
return [n.name for n in d.decks.all_names_and_ids(skip_empty_default=True)]
languages_did = d.decks.id("Languages")
chinese_did = d.decks.id("Chinese")
@ -151,16 +153,3 @@ def test_renameForDragAndDrop():
# '' is a convenient alias for the top level DID
d.decks.renameForDragAndDrop(hsk_did, "")
assert deckNames() == ["Chinese", "HSK", "Languages"]
def test_check():
d = getEmptyCol()
# currently disabled - see 5418af00f733ca62b0c087d1422feae01d6571b0
# foo_did = d.decks.id("foo")
# FOO_did = d.decks.id("bar")
# FOO = d.decks.byName("bar")
# FOO["name"] = "FOO"
# d.decks.save(FOO)
# d.decks._checkDeckTree()
# assert "foo" not in d.decks.allNames() or "FOO" not in d.decks.allNames()

View file

@ -441,9 +441,9 @@ def test_review_limits():
c.flush()
tree = d.sched.deckDueTree()
# (('Default', 1, 0, 0, 0, ()), ('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),)))
assert tree[1][2] == 5 # parent
assert tree[1][5][0][2] == 5 # child
# (('parent', 1514457677462, 5, 0, 0, (('child', 1514457677463, 5, 0, 0, ()),)))
assert tree[0][2] == 5 # parent
assert tree[0][5][0][2] == 5 # child
# .counts() should match
d.decks.select(child["id"])
@ -456,8 +456,8 @@ def test_review_limits():
assert d.sched.counts() == (0, 0, 4)
tree = d.sched.deckDueTree()
assert tree[1][2] == 4 # parent
assert tree[1][5][0][2] == 4 # child
assert tree[0][2] == 4 # parent
assert tree[0][5][0][2] == 4 # child
def test_button_spacing():

View file

@ -1148,11 +1148,6 @@ QTableView {{ gridline-color: {grid} }}
def fillGroups(root, nodes: Sequence[DeckTreeNode], head=""):
for node in nodes:
if node.deck_id == 1 and not node.children:
if not self.mw.col.decks.should_default_be_displayed(
force_default=False, assume_no_child=True
):
continue
def set_filter():
full_name = head + node.name # pylint: disable=cell-var-from-loop

View file

@ -49,7 +49,8 @@ class ExportDialog(QDialog):
self.exporterChanged(idx)
# deck list
if self.cids is None:
self.decks = [_("All Decks")] + sorted(self.col.decks.allNames())
self.decks = [_("All Decks")]
self.decks.extend(d.name for d in self.col.decks.all_names_and_ids())
else:
self.decks = [_("Selected Notes")]
self.frm.deck.addItems(self.decks)

View file

@ -1115,8 +1115,7 @@ title="%s" %s>%s</button>""" % (
if not search:
if not deck["dyn"]:
search = 'deck:"%s" ' % deck["name"]
decks = self.col.decks.allNames()
while _("Filtered Deck %d") % n in decks:
while self.col.decks.id_for_name(_("Filtered Deck %d") % n):
n += 1
name = _("Filtered Deck %d") % n
did = self.col.decks.newDyn(name)

View file

@ -3,7 +3,6 @@
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import aqt
from anki.decks import DeckManager
from anki.lang import _
from aqt import gui_hooks
from aqt.qt import *
@ -52,10 +51,12 @@ class StudyDeck(QDialog):
if title:
self.setWindowTitle(title)
if not names:
names = sorted(
self.mw.col.decks.allNames(dyn=dyn, force_default=False),
key=DeckManager._path,
)
names = [
d.name
for d in self.mw.col.decks.all_names_and_ids(
include_filtered=dyn, skip_empty_default=True
)
]
self.nameFunc = None
self.origNames = names
else:

View file

@ -32,9 +32,9 @@ class TagEdit(QLineEdit):
"Set the current col, updating list of available tags."
self.col = col
if self.type == 0:
l = sorted(self.col.tags.all())
l = self.col.tags.all()
else:
l = sorted(self.col.decks.allNames())
l = (d.name for d in self.col.decks.all_names_and_ids())
self.model.setStringList(l)
def focusInEvent(self, evt):

View file

@ -341,7 +341,7 @@ impl Backend {
Value::GetDeckIdByName(name) => {
OValue::GetDeckIdByName(self.get_deck_id_by_name(&name)?)
}
Value::GetDeckNames(_) => OValue::GetDeckNames(self.get_deck_names()?),
Value::GetDeckNames(input) => OValue::GetDeckNames(self.get_deck_names(input)?),
Value::AddOrUpdateDeckLegacy(input) => {
OValue::AddOrUpdateDeckLegacy(self.add_or_update_deck_legacy(input)?)
}
@ -1011,9 +1011,13 @@ impl Backend {
})
}
fn get_deck_names(&self) -> Result<pb::DeckNames> {
fn get_deck_names(&self, input: pb::GetDeckNamesIn) -> Result<pb::DeckNames> {
self.with_col(|col| {
let names = col.storage.get_all_deck_names()?;
let names = if input.include_filtered {
col.get_all_deck_names(input.skip_empty_default)?
} else {
col.get_all_normal_deck_names()?
};
Ok(pb::DeckNames {
entries: names
.into_iter()

View file

@ -187,6 +187,10 @@ fn immediate_parent_name(machine_name: &str) -> Option<&str> {
}
impl Collection {
pub(crate) fn default_deck_is_empty(&self) -> Result<bool> {
self.storage.deck_is_empty(DeckID(1))
}
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();
@ -416,6 +420,32 @@ impl Collection {
}
Ok(())
}
pub fn get_all_deck_names(&self, skip_empty_default: bool) -> Result<Vec<(DeckID, String)>> {
if skip_empty_default && self.default_deck_is_empty()? {
Ok(self
.storage
.get_all_deck_names()?
.into_iter()
.filter(|(id, _name)| id.0 != 1)
.collect())
} else {
self.storage.get_all_deck_names()
}
}
pub fn get_all_normal_deck_names(&mut self) -> Result<Vec<(DeckID, String)>> {
Ok(self
.storage
.get_all_deck_names()?
.into_iter()
.filter(|(id, _name)| id.0 != 1)
.filter(|(id, _name)| match self.get_deck(*id) {
Ok(Some(deck)) => !deck.is_filtered(),
_ => true,
})
.collect())
}
}
#[cfg(test)]

View file

@ -151,6 +151,21 @@ fn remaining_counts_for_deck(
}
}
fn hide_default_deck(node: &mut DeckTreeNode) {
for (idx, child) in node.children.iter().enumerate() {
// we can hide the default if it has no children
if child.deck_id == 1 && child.children.is_empty() {
if child.level == 1 && node.children.len() == 1 {
// can't remove if there are no other decks
} else {
// safe to remove
node.children.remove(idx);
}
return;
}
}
}
#[derive(Serialize_tuple)]
pub(crate) struct LegacyDueCounts {
name: String,
@ -187,6 +202,9 @@ impl Collection {
.collect();
add_collapsed(&mut tree, &decks_map, !counts);
if self.default_deck_is_empty()? {
hide_default_deck(&mut tree);
}
if counts {
let counts = self.due_counts()?;
@ -251,8 +269,7 @@ mod test {
let tree = col.deck_tree(false)?;
// 4 including default
assert_eq!(tree.children.len(), 4);
assert_eq!(tree.children.len(), 3);
assert_eq!(tree.children[1].name, "2");
assert_eq!(tree.children[1].children[0].name, "a");
@ -274,7 +291,7 @@ mod test {
col.storage.remove_deck(col.get_deck_id("2::3")?.unwrap())?;
let tree = col.deck_tree(false)?;
assert_eq!(tree.children.len(), 2);
assert_eq!(tree.children.len(), 1);
Ok(())
}

View file

@ -178,6 +178,15 @@ impl SqliteStorage {
.collect()
}
pub(crate) fn deck_is_empty(&self, did: DeckID) -> Result<bool> {
self.db
.prepare_cached("select null from cards where did=?")?
.query(&[did])?
.next()
.map(|o| o.is_none())
.map_err(Into::into)
}
// Upgrading/downgrading/legacy
pub(super) fn add_default_deck(&self, i18n: &I18n) -> Result<()> {