From 2168dfe63d16188b97fd85f743a3fdc9fb2776ac Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Mon, 5 Apr 2021 10:21:50 +1000 Subject: [PATCH] add routine to set deck collapse state Updating a deck via protobuf is now exposed on the backend, but not currently on the frontend - I suspect we'll be better off writing separate routines for the actions we need instead, and we get a better undo description for free. This is currently causing an ugly redraw in the browse screen, which will need fixing. --- ftl/core/undo.ftl | 1 + pylib/.pylintrc | 3 +- pylib/anki/_backend/__init__.py | 3 ++ pylib/anki/collection.py | 6 ++- pylib/anki/decks.py | 8 +++ qt/aqt/deckbrowser.py | 12 +++-- qt/aqt/operations/deck.py | 12 ++++- qt/aqt/sidebar.py | 24 ++++++--- rslib/backend.proto | 13 +++++ rslib/src/backend/decks.rs | 96 +++++++++++++++++++++++---------- rslib/src/decks/mod.rs | 26 ++------- rslib/src/decks/schema11.rs | 6 +-- rslib/src/decks/tree.rs | 23 ++++++++ rslib/src/ops.rs | 2 + 14 files changed, 168 insertions(+), 67 deletions(-) diff --git a/ftl/core/undo.ftl b/ftl/core/undo.ftl index 4a42fed14..f3e32ccaa 100644 --- a/ftl/core/undo.ftl +++ b/ftl/core/undo.ftl @@ -23,3 +23,4 @@ undo-update-deck = Update Deck undo-forget-card = Forget Card undo-set-flag = Set Flag undo-build-filtered-deck = Build Deck +undo-expand-collapse = Expand/Collapse diff --git a/pylib/.pylintrc b/pylib/.pylintrc index 50ba2337b..915320c1e 100644 --- a/pylib/.pylintrc +++ b/pylib/.pylintrc @@ -10,7 +10,8 @@ ignored-classes= UnburyCardsInCurrentDeckIn, BuryOrSuspendCardsIn, NoteIsDuplicateOrEmptyOut, - BackendError + BackendError, + SetDeckCollapsedIn, [REPORTS] output-format=colorized diff --git a/pylib/anki/_backend/__init__.py b/pylib/anki/_backend/__init__.py index b5f776de3..f9ae8a9b8 100644 --- a/pylib/anki/_backend/__init__.py +++ b/pylib/anki/_backend/__init__.py @@ -36,6 +36,9 @@ from . import backend_pb2 as pb from . import rsbridge from .fluent import GeneratedTranslations, LegacyTranslationEnum +# the following comment is required to suppress a warning that only shows up +# when there are other pylint failures +# pylint: disable=c-extension-no-member assert rsbridge.buildhash() == anki.buildinfo.buildhash diff --git a/pylib/anki/collection.py b/pylib/anki/collection.py index 8eea0dfd8..44a102bb7 100644 --- a/pylib/anki/collection.py +++ b/pylib/anki/collection.py @@ -332,9 +332,13 @@ class Collection: ) def get_deck(self, id: DeckId) -> Deck: - "Get a new-style deck object. Currently read-only." + "Get a new-style deck object." return self._backend.get_deck(id) + def update_deck(self, deck: Deck) -> OpChanges: + "Save updates to an existing deck." + return self._backend.update_deck(deck) + def get_deck_config(self, id: DeckConfigId) -> DeckConfig: "Get a new-style deck config object. Currently read-only." return self._backend.get_deck_config(id) diff --git a/pylib/anki/decks.py b/pylib/anki/decks.py index 7ba21c418..8eb6807e6 100644 --- a/pylib/anki/decks.py +++ b/pylib/anki/decks.py @@ -21,6 +21,7 @@ from anki.utils import from_json_bytes, ids2str, intTime, legacy_func, to_json_b DeckTreeNode = _pb.DeckTreeNode DeckNameId = _pb.DeckNameId FilteredDeckConfig = _pb.Deck.Filtered +DeckCollapseScope = _pb.SetDeckCollapsedIn.Scope # legacy code may pass this in as the type argument to .id() defaultDeck = 0 @@ -224,6 +225,13 @@ class DeckManager: ) ] + def set_collapsed( + self, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V + ) -> OpChanges: + return self.col._backend.set_deck_collapsed( + deck_id=deck_id, collapsed=collapsed, scope=scope + ) + def collapse(self, did: DeckId) -> None: deck = self.get(did) deck["collapsed"] = not deck["collapsed"] diff --git a/qt/aqt/deckbrowser.py b/qt/aqt/deckbrowser.py index 6e459a520..34c371eb6 100644 --- a/qt/aqt/deckbrowser.py +++ b/qt/aqt/deckbrowser.py @@ -9,7 +9,7 @@ from typing import Any import aqt from anki.collection import OpChanges -from anki.decks import Deck, DeckId, DeckTreeNode +from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode from anki.utils import intTime from aqt import AnkiQt, gui_hooks from aqt.operations.deck import ( @@ -17,6 +17,7 @@ from aqt.operations.deck import ( remove_decks, rename_deck, reparent_decks, + set_deck_collapsed, ) from aqt.qt import * from aqt.sound import av_player @@ -286,11 +287,16 @@ class DeckBrowser: self.mw.onDeckConf() def _collapse(self, did: DeckId) -> None: - self.mw.col.decks.collapse(did) node = self.mw.col.decks.find_deck_in_tree(self._dueTree, did) if node: node.collapsed = not node.collapsed - self._renderPage(reuse=True) + set_deck_collapsed( + mw=self.mw, + deck_id=did, + collapsed=node.collapsed, + scope=DeckCollapseScope.REVIEWER, + ) + self._renderPage(reuse=True) def _handle_drag_and_drop(self, source: DeckId, target: DeckId) -> None: reparent_decks(mw=self.mw, parent=self.mw, deck_ids=[source], new_parent=target) diff --git a/qt/aqt/operations/deck.py b/qt/aqt/operations/deck.py index 558e203c9..92fd3f1d6 100644 --- a/qt/aqt/operations/deck.py +++ b/qt/aqt/operations/deck.py @@ -5,7 +5,7 @@ from __future__ import annotations from typing import Callable, Sequence -from anki.decks import DeckId +from anki.decks import DeckCollapseScope, DeckId from aqt import AnkiQt, QWidget from aqt.main import PerformOpOptionalSuccessCallback from aqt.utils import getOnlyText, tooltip, tr @@ -68,3 +68,13 @@ def add_deck( lambda: mw.col.decks.add_normal_deck_with_name(name), success=success, ) + + +def set_deck_collapsed( + *, mw: AnkiQt, deck_id: DeckId, collapsed: bool, scope: DeckCollapseScope.V +) -> None: + mw.perform_op( + lambda: mw.col.decks.set_collapsed( + deck_id=deck_id, collapsed=collapsed, scope=scope + ) + ) diff --git a/qt/aqt/sidebar.py b/qt/aqt/sidebar.py index 037ff48da..63485b4ed 100644 --- a/qt/aqt/sidebar.py +++ b/qt/aqt/sidebar.py @@ -8,7 +8,7 @@ from typing import Dict, Iterable, List, Optional, Tuple, cast import aqt from anki.collection import Config, OpChanges, SearchJoiner, SearchNode -from anki.decks import Deck, DeckId, DeckTreeNode +from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode from anki.models import NotetypeId from anki.notes import Note from anki.tags import TagTreeNode @@ -16,7 +16,12 @@ from anki.types import assert_exhaustive from aqt import colors, gui_hooks from aqt.clayout import CardLayout from aqt.models import Models -from aqt.operations.deck import remove_decks, rename_deck, reparent_decks +from aqt.operations.deck import ( + remove_decks, + rename_deck, + reparent_decks, + set_deck_collapsed, +) from aqt.operations.tag import remove_tags_from_all_notes, rename_tag, reparent_tags from aqt.qt import * from aqt.theme import ColoredIcon, theme_manager @@ -965,17 +970,20 @@ class SidebarTreeView(QTreeView): def render( root: SidebarItem, nodes: Iterable[DeckTreeNode], head: str = "" ) -> None: + def toggle_expand(node: DeckTreeNode) -> Callable[[bool], None]: + return lambda expanded: set_deck_collapsed( + mw=self.mw, + deck_id=DeckId(node.deck_id), + collapsed=not expanded, + scope=DeckCollapseScope.BROWSER, + ) + for node in nodes: - - def toggle_expand() -> Callable[[bool], None]: - did = DeckId(node.deck_id) # pylint: disable=cell-var-from-loop - return lambda _: self.mw.col.decks.collapseBrowser(did) - item = SidebarItem( name=node.name, icon=icon, search_node=SearchNode(deck=head + node.name), - on_expanded=toggle_expand(), + on_expanded=toggle_expand(node), expanded=not node.collapsed, item_type=SidebarItemType.DECK, id=node.deck_id, diff --git a/rslib/backend.proto b/rslib/backend.proto index 421c6a21b..60a5fb6d0 100644 --- a/rslib/backend.proto +++ b/rslib/backend.proto @@ -143,6 +143,8 @@ service DecksService { rpc GetAllDecksLegacy(Empty) returns (Json); rpc GetDeckIdByName(String) returns (DeckId); rpc GetDeck(DeckId) returns (Deck); + rpc UpdateDeck(Deck) returns (OpChanges); + rpc SetDeckCollapsed(SetDeckCollapsedIn) returns (OpChanges); rpc GetDeckLegacy(DeckId) returns (Json); rpc GetDeckNames(GetDeckNamesIn) returns (DeckNames); rpc NewDeckLegacy(Bool) returns (Json); @@ -916,6 +918,17 @@ message SetTagExpandedIn { bool expanded = 2; } +message SetDeckCollapsedIn { + enum Scope { + REVIEWER = 0; + BROWSER = 1; + } + + int64 deck_id = 1; + bool collapsed = 2; + Scope scope = 3; +} + message GetChangedTagsOut { repeated string tags = 1; } diff --git a/rslib/src/backend/decks.rs b/rslib/src/backend/decks.rs index 99a41eb3a..a4d72b396 100644 --- a/rslib/src/backend/decks.rs +++ b/rslib/src/backend/decks.rs @@ -1,10 +1,15 @@ // Copyright: Ankitects Pty Ltd and contributors // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +use std::convert::TryFrom; + use super::Backend; use crate::{ backend_proto::{self as pb}, - decks::{Deck, DeckId, DeckSchema11, FilteredSearchOrder}, + decks::{ + human_deck_name_to_native, native_deck_name_to_human, Deck, DeckId, DeckSchema11, + FilteredSearchOrder, + }, prelude::*, scheduler::filtered::FilteredDeckForUpdate, }; @@ -89,6 +94,13 @@ impl DecksService for Backend { }) } + fn update_deck(&self, input: pb::Deck) -> Result { + self.with_col(|col| { + let mut deck = Deck::try_from(input)?; + col.update_deck(&mut deck).map(Into::into) + }) + } + fn get_deck_legacy(&self, input: pb::DeckId) -> Result { self.with_col(|col| { let deck: DeckSchema11 = col @@ -168,6 +180,13 @@ impl DecksService for Backend { fn filtered_deck_order_labels(&self, _input: pb::Empty) -> Result { Ok(FilteredSearchOrder::labels(&self.tr).into()) } + + fn set_deck_collapsed(&self, input: pb::SetDeckCollapsedIn) -> Result { + self.with_col(|col| { + col.set_deck_collapsed(input.deck_id.into(), input.collapsed, input.scope()) + }) + .map(Into::into) + } } impl From for DeckId { @@ -208,8 +227,54 @@ impl From for FilteredDeckForUpdate { } } -// before we can switch to returning protobuf, we need to make sure we're converting the -// deck separators +impl From for pb::Deck { + fn from(d: Deck) -> Self { + pb::Deck { + id: d.id.0, + name: native_deck_name_to_human(&d.name), + mtime_secs: d.mtime_secs.0, + usn: d.usn.0, + common: Some(d.common), + kind: Some(d.kind.into()), + } + } +} + +impl TryFrom for Deck { + type Error = AnkiError; + + fn try_from(d: pb::Deck) -> Result { + Ok(Deck { + id: DeckId(d.id), + name: human_deck_name_to_native(&d.name), + mtime_secs: TimestampSecs(d.mtime_secs), + usn: Usn(d.usn), + common: d.common.unwrap_or_default(), + kind: d + .kind + .ok_or_else(|| AnkiError::invalid_input("missing 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), + } + } +} + +impl From for DeckKind { + fn from(kind: pb::deck::Kind) -> Self { + match kind { + pb::deck::Kind::Normal(normal) => DeckKind::Normal(normal), + pb::deck::Kind::Filtered(filtered) => DeckKind::Filtered(filtered), + } + } +} // fn new_deck(&self, input: pb::Bool) -> Result { // let deck = if input.val { @@ -219,28 +284,3 @@ impl From for FilteredDeckForUpdate { // }; // Ok(deck.into()) // } - -// impl From for Deck { -// fn from(deck: pb::Deck) -> Self { -// Self { -// id: deck.id.into(), -// name: deck.name, -// mtime_secs: deck.mtime_secs.into(), -// usn: deck.usn.into(), -// common: deck.common.unwrap_or_default(), -// kind: deck -// .kind -// .map(Into::into) -// .unwrap_or_else(|| DeckKind::Normal(NormalDeck::default())), -// } -// } -// } - -// impl From for DeckKind { -// fn from(kind: pb::deck::Kind) -> Self { -// match kind { -// pb::deck::Kind::Normal(normal) => DeckKind::Normal(normal), -// pb::deck::Kind::Filtered(filtered) => DeckKind::Filtered(filtered), -// } -// } -// } diff --git a/rslib/src/decks/mod.rs b/rslib/src/decks/mod.rs index 5262e7d6f..22a2e6db9 100644 --- a/rslib/src/decks/mod.rs +++ b/rslib/src/decks/mod.rs @@ -207,6 +207,10 @@ pub(crate) fn human_deck_name_to_native(name: &str) -> String { out.trim_end_matches('\x1f').into() } +pub(crate) fn native_deck_name_to_human(name: &str) -> String { + name.replace('\x1f', "::") +} + impl Collection { pub(crate) fn get_deck(&mut self, did: DeckId) -> Result>> { if let Some(deck) = self.state.deck_cache.get(&did) { @@ -222,28 +226,6 @@ 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, - 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), - } - } -} - pub(crate) fn immediate_parent_name(machine_name: &str) -> Option<&str> { machine_name.rsplitn(2, '\x1f').nth(1) } diff --git a/rslib/src/decks/schema11.rs b/rslib/src/decks/schema11.rs index 6b28352ff..2b86630a6 100644 --- a/rslib/src/decks/schema11.rs +++ b/rslib/src/decks/schema11.rs @@ -3,8 +3,8 @@ use super::DeckId; use super::{ - human_deck_name_to_native, Deck, DeckCommon, DeckKind, FilteredDeck, FilteredSearchTerm, - NormalDeck, + human_deck_name_to_native, native_deck_name_to_human, Deck, DeckCommon, DeckKind, FilteredDeck, + FilteredSearchTerm, NormalDeck, }; use crate::{ serde::{default_on_invalid, deserialize_bool_from_anything, deserialize_number_from_string}, @@ -363,7 +363,7 @@ impl From for DeckCommonSchema11 { DeckCommonSchema11 { id: deck.id, mtime: deck.mtime_secs, - name: deck.name.replace("\x1f", "::"), + name: native_deck_name_to_human(&deck.name), usn: deck.usn, today: (&deck).into(), study_collapsed: deck.common.study_collapsed, diff --git a/rslib/src/decks/tree.rs b/rslib/src/decks/tree.rs index 23d3a9d2d..d4fa199e6 100644 --- a/rslib/src/decks/tree.rs +++ b/rslib/src/decks/tree.rs @@ -2,6 +2,7 @@ // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html use super::{Deck, DeckKind, DueCounts}; +pub use crate::backend_proto::set_deck_collapsed_in::Scope as DeckCollapseScope; use crate::{ backend_proto::DeckTreeNode, collection::Collection, @@ -9,7 +10,9 @@ use crate::{ deckconf::{DeckConf, DeckConfId}, decks::DeckId, error::Result, + ops::OpOutput, timestamp::TimestampSecs, + undo::Op, }; use serde_tuple::Serialize_tuple; use std::{ @@ -312,6 +315,26 @@ impl Collection { Ok(get_subnode(tree, target)) } + pub fn set_deck_collapsed( + &mut self, + did: DeckId, + collapsed: bool, + scope: DeckCollapseScope, + ) -> Result> { + self.transact(Op::ExpandCollapse, |col| { + if let Some(mut deck) = col.storage.get_deck(did)? { + let original = deck.clone(); + let c = &mut deck.common; + match scope { + DeckCollapseScope::Reviewer => c.study_collapsed = collapsed, + DeckCollapseScope::Browser => c.browser_collapsed = collapsed, + }; + col.update_deck_inner(&mut deck, original, col.usn()?)?; + } + Ok(()) + }) + } + pub(crate) fn legacy_deck_tree(&mut self) -> Result { let tree = self.deck_tree(Some(TimestampSecs::now()), None)?; Ok(LegacyDueCounts::from(tree)) diff --git a/rslib/src/ops.rs b/rslib/src/ops.rs index 0d0b2c386..6e7503e48 100644 --- a/rslib/src/ops.rs +++ b/rslib/src/ops.rs @@ -12,6 +12,7 @@ pub enum Op { Bury, ClearUnusedTags, EmptyFilteredDeck, + ExpandCollapse, FindAndReplace, RebuildFilteredDeck, RemoveDeck, @@ -66,6 +67,7 @@ impl Op { Op::BuildFilteredDeck => tr.undo_build_filtered_deck(), Op::RebuildFilteredDeck => tr.undo_build_filtered_deck(), Op::EmptyFilteredDeck => tr.studying_empty(), + Op::ExpandCollapse => tr.undo_expand_collapse(), } .into() }