anki/qt/aqt/deckbrowser.py

384 lines
12 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
2021-03-22 09:23:56 +01:00
from __future__ import annotations
2019-12-20 10:19:03 +01:00
from copy import deepcopy
from dataclasses import dataclass
from typing import Any, Optional
import aqt
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
from anki.collection import OpChanges
from anki.decks import Deck, DeckCollapseScope, DeckId, DeckTreeNode
from anki.utils import intTime
from aqt import AnkiQt, gui_hooks
2021-04-03 08:26:10 +02:00
from aqt.operations.deck import (
add_deck_dialog,
remove_decks,
rename_deck,
reparent_decks,
set_deck_collapsed,
2021-04-03 08:26:10 +02:00
)
2019-12-20 10:19:03 +01:00
from aqt.qt import *
2020-01-20 11:10:38 +01:00
from aqt.sound import av_player
from aqt.toolbar import BottomBar
from aqt.utils import askUser, getOnlyText, openLink, shortcut, showInfo, tr
2019-12-20 10:19:03 +01:00
class DeckBrowserBottomBar:
2021-02-01 14:28:21 +01:00
def __init__(self, deck_browser: DeckBrowser) -> None:
self.deck_browser = deck_browser
@dataclass
class DeckBrowserContent:
"""Stores sections of HTML content that the deck browser will be
populated with.
2020-08-31 05:29:28 +02:00
Attributes:
tree {str} -- HTML of the deck tree section
stats {str} -- HTML of the stats section
"""
tree: str
stats: str
@dataclass
class RenderDeckNodeContext:
current_deck_id: DeckId
class DeckBrowser:
_dueTree: DeckTreeNode
def __init__(self, mw: AnkiQt) -> None:
self.mw = mw
self.web = mw.web
self.bottom = BottomBar(mw, mw.bottomWeb)
self.scrollPos = QPoint(0, 0)
self._v1_message_dismissed_at = 0
self._refresh_needed = False
2021-02-01 14:28:21 +01:00
def show(self) -> None:
2020-01-20 11:10:38 +01:00
av_player.stop_and_clear_queue()
self.web.set_bridge_command(self._linkHandler, self)
self._renderPage()
# redraw top bar for theme change
self.mw.toolbar.redraw()
self.refresh()
2021-02-01 14:28:21 +01:00
def refresh(self) -> None:
self._renderPage()
self._refresh_needed = False
def refresh_if_needed(self) -> None:
if self._refresh_needed:
self.refresh()
2021-03-14 10:54:15 +01:00
def op_executed(
self, changes: OpChanges, handler: Optional[object], focused: bool
) -> bool:
if changes.study_queues:
self._refresh_needed = True
if focused:
self.refresh_if_needed()
2021-03-14 10:54:15 +01:00
return self._refresh_needed
# Event handlers
##########################################################################
2021-01-30 11:37:29 +01:00
def _linkHandler(self, url: str) -> Any:
if ":" in url:
(cmd, arg) = url.split(":", 1)
else:
cmd = url
if cmd == "open":
self._selDeck(arg)
elif cmd == "opts":
self._showOptions(arg)
elif cmd == "shared":
self._onShared()
elif cmd == "import":
self.mw.onImport()
elif cmd == "create":
2021-02-24 13:59:38 +01:00
self._on_create()
elif cmd == "drag":
2021-01-30 11:37:29 +01:00
source, target = arg.split(",")
self._handle_drag_and_drop(DeckId(int(source)), DeckId(int(target or 0)))
elif cmd == "collapse":
self._collapse(DeckId(int(arg)))
elif cmd == "v2upgrade":
self._confirm_upgrade()
elif cmd == "v2upgradeinfo":
openLink("https://faqs.ankiweb.net/the-anki-2.1-scheduler.html")
elif cmd == "v2upgradelater":
self._v1_message_dismissed_at = intTime()
self.refresh()
return False
def _selDeck(self, did: str) -> None:
self.mw.col.decks.select(DeckId(int(did)))
self.mw.onOverview()
# HTML generation
##########################################################################
_body = """
<center>
<table cellspacing=0 cellpading=3>
%(tree)s
</table>
<br>
%(stats)s
</center>
"""
def _renderPage(self, reuse: bool = False) -> None:
if not reuse:
self._dueTree = self.mw.col.sched.deck_due_tree()
self.__renderPage(None)
2019-06-01 08:35:19 +02:00
return
self.web.evalWithCallback("window.pageYOffset", self.__renderPage)
def __renderPage(self, offset: int) -> None:
content = DeckBrowserContent(
2020-08-31 05:29:28 +02:00
tree=self._renderDeckTree(self._dueTree),
stats=self._renderStats(),
)
gui_hooks.deck_browser_will_render_content(self, content)
2019-12-23 01:34:10 +01:00
self.web.stdHtml(
self._v1_upgrade_message() + self._body % content.__dict__,
css=["css/deckbrowser.css"],
js=[
"js/vendor/jquery.min.js",
"js/vendor/jquery-ui.min.js",
"js/deckbrowser.js",
],
context=self,
2019-12-23 01:34:10 +01:00
)
self._drawButtons()
if offset is not None:
self._scrollToOffset(offset)
2020-04-09 05:40:19 +02:00
gui_hooks.deck_browser_did_render(self)
def _scrollToOffset(self, offset: int) -> None:
self.web.eval("$(function() { window.scrollTo(0, %d, 'instant'); });" % offset)
2021-02-01 14:28:21 +01:00
def _renderStats(self) -> str:
return '<div id="studiedToday"><span>{}</span></div>'.format(
self.mw.col.studied_today(),
)
def _renderDeckTree(self, top: DeckTreeNode) -> str:
buf = """
<tr><th colspan=5 align=start>%s</th><th class=count>%s</th>
<th class=count>%s</th><th class=optscol></th></tr>""" % (
2021-03-26 04:48:26 +01:00
tr.decks_deck(),
tr.statistics_due_count(),
tr.actions_new(),
)
buf += self._topLevelDragRow()
ctx = RenderDeckNodeContext(current_deck_id=self.mw.col.conf["curDeck"])
for child in top.children:
buf += self._render_deck_node(child, ctx)
return buf
def _render_deck_node(self, node: DeckTreeNode, ctx: RenderDeckNodeContext) -> str:
if node.collapsed:
prefix = "+"
else:
prefix = "-"
due = node.review_count + node.learn_count
2019-12-23 01:34:10 +01:00
2021-02-01 14:28:21 +01:00
def indent() -> str:
return "&nbsp;" * 6 * (node.level - 1)
2019-12-23 01:34:10 +01:00
if node.deck_id == ctx.current_deck_id:
2019-12-23 01:34:10 +01:00
klass = "deck current"
else:
2019-12-23 01:34:10 +01:00
klass = "deck"
buf = "<tr class='%s' id='%d'>" % (klass, node.deck_id)
# deck link
if node.children:
2019-12-23 01:34:10 +01:00
collapse = (
"<a class=collapse href=# onclick='return pycmd(\"collapse:%d\")'>%s</a>"
% (node.deck_id, prefix)
2019-12-23 01:34:10 +01:00
)
else:
collapse = "<span class=collapse></span>"
if node.filtered:
extraclass = "filtered"
else:
extraclass = ""
buf += """
<td class=decktd colspan=5>%s%s<a class="deck %s"
2019-12-23 01:34:10 +01:00
href=# onclick="return pycmd('open:%d')">%s</a></td>""" % (
indent(),
collapse,
extraclass,
node.deck_id,
node.name,
2019-12-23 01:34:10 +01:00
)
# due counts
2021-02-01 14:28:21 +01:00
def nonzeroColour(cnt: int, klass: str) -> str:
if not cnt:
klass = "zero-count"
return f'<span class="{klass}">{cnt}</span>'
2019-12-23 01:34:10 +01:00
buf += "<td align=right>%s</td><td align=right>%s</td>" % (
nonzeroColour(due, "review-count"),
nonzeroColour(node.new_count, "new-count"),
2019-12-23 01:34:10 +01:00
)
# options
2019-12-23 01:34:10 +01:00
buf += (
"<td align=center class=opts><a onclick='return pycmd(\"opts:%d\");'>"
"<img src='/_anki/imgs/gears.svg' class=gears></a></td></tr>" % node.deck_id
2019-12-23 01:34:10 +01:00
)
# children
if not node.collapsed:
for child in node.children:
buf += self._render_deck_node(child, ctx)
return buf
2021-02-01 14:28:21 +01:00
def _topLevelDragRow(self) -> str:
return "<tr class='top-level-drag-row'><td colspan='6'>&nbsp;</td></tr>"
# Options
##########################################################################
def _showOptions(self, did: str) -> None:
m = QMenu(self.mw)
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.actions_rename())
qconnect(a.triggered, lambda b, did=did: self._rename(DeckId(int(did))))
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.actions_options())
qconnect(a.triggered, lambda b, did=did: self._options(DeckId(int(did))))
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.actions_export())
qconnect(a.triggered, lambda b, did=did: self._export(DeckId(int(did))))
2021-03-26 04:48:26 +01:00
a = m.addAction(tr.actions_delete())
qconnect(a.triggered, lambda b, did=did: self._delete(DeckId(int(did))))
2020-05-22 03:27:40 +02:00
gui_hooks.deck_browser_will_show_options_menu(m, int(did))
m.exec_(QCursor.pos())
def _export(self, did: DeckId) -> None:
2014-06-20 02:13:12 +02:00
self.mw.onExport(did=did)
def _rename(self, did: DeckId) -> None:
def prompt(deck: Deck) -> None:
new_name = getOnlyText(tr.decks_new_deck_name(), default=deck.name)
if not new_name or new_name == deck.name:
return
else:
rename_deck(mw=self.mw, deck_id=did, new_name=new_name)
self.mw.query_op(lambda: self.mw.col.get_deck(did), success=prompt)
def _options(self, did: DeckId) -> None:
# select the deck first, because the dyn deck conf assumes the deck
# we're editing is the current one
self.mw.col.decks.select(did)
self.mw.onDeckConf()
def _collapse(self, did: DeckId) -> None:
2020-05-16 05:05:20 +02:00
node = self.mw.col.decks.find_deck_in_tree(self._dueTree, did)
if node:
node.collapsed = not node.collapsed
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:
2021-03-22 09:23:56 +01:00
reparent_decks(mw=self.mw, parent=self.mw, deck_ids=[source], new_parent=target)
def _delete(self, did: DeckId) -> None:
undoable ops now return changes directly; add new *_ops.py files - Introduced a new transact() method that wraps the return value in a separate struct that describes the changes that were made. - Changes are now gathered from the undo log, so we don't need to guess at what was changed - eg if update_note() is called with identical note contents, no changes are returned. Card changes will only be set if cards were actually generated by the update_note() call, and tag will only be set if a new tag was added. - mw.perform_op() has been updated to expect the op to return the changes, or a structure with the changes in it, and it will use them to fire the change hook, instead of fetching the changes from undo_status(), so there is no risk of race conditions. - the various calls to mw.perform_op() have been split into separate files like card_ops.py. Aside from making the code cleaner, this works around a rather annoying issue with mypy. Because we run it with no_strict_optional, mypy is happy to accept an operation that returns None, despite the type signature saying it requires changes to be returned. Turning no_strict_optional on for the whole codebase is not practical at the moment, but we can enable it for individual files. Still todo: - The cursor keeps moving back to the start of a field when typing - we need to ignore the refresh hook when we are the initiator. - The busy cursor icon should probably be delayed a few hundreds ms. - Still need to think about a nicer way of handling saveNow() - op_made_changes(), op_affects_study_queue() might be better embedded as properties in the object instead
2021-03-16 05:26:42 +01:00
remove_decks(mw=self.mw, parent=self.mw, deck_ids=[did])
# Top buttons
######################################################################
drawLinks = [
2021-03-26 04:48:26 +01:00
["", "shared", tr.decks_get_shared()],
["", "create", tr.decks_create_deck()],
["Ctrl+Shift+I", "import", tr.decks_import_file()],
]
2021-02-01 14:28:21 +01:00
def _drawButtons(self) -> None:
buf = ""
drawLinks = deepcopy(self.drawLinks)
for b in drawLinks:
if b[0]:
b[0] = tr.actions_shortcut_key(val=shortcut(b[0]))
buf += """
2019-12-23 01:34:10 +01:00
<button title='%s' onclick='pycmd(\"%s\");'>%s</button>""" % tuple(
b
)
self.bottom.draw(
buf=buf,
link_handler=self._linkHandler,
web_context=DeckBrowserBottomBar(self),
)
2021-02-01 14:28:21 +01:00
def _onShared(self) -> None:
openLink(f"{aqt.appShared}decks/")
2021-02-24 13:59:38 +01:00
def _on_create(self) -> None:
2021-03-22 14:17:07 +01:00
add_deck_dialog(mw=self.mw, parent=self.mw)
2021-02-24 13:59:38 +01:00
######################################################################
def _v1_upgrade_message(self) -> str:
if self.mw.col.schedVer() == 2:
return ""
if (intTime() - self._v1_message_dismissed_at) < 86_400:
return ""
return f"""
<center>
<div class=callout>
<div>
2021-03-26 04:48:26 +01:00
{tr.scheduling_update_soon()}
</div>
<div>
<button onclick='pycmd("v2upgrade")'>
2021-03-26 04:48:26 +01:00
{tr.scheduling_update_button()}
</button>
<button onclick='pycmd("v2upgradeinfo")'>
2021-03-26 04:48:26 +01:00
{tr.scheduling_update_more_info_button()}
</button>
<button onclick='pycmd("v2upgradelater")'>
2021-03-26 04:48:26 +01:00
{tr.scheduling_update_later_button()}
</button>
</div>
</div>
</center>
"""
def _confirm_upgrade(self) -> None:
self.mw.col.modSchema(check=True)
self.mw.col.upgrade_to_v2_scheduler()
# not translated, as 2.15 should not be too far off
if askUser(
"Do you use AnkiDroid <= 2.14, or plan to use it in the near future? If unsure, choose No. You can adjust the setting later in the preferences screen.",
defaultno=True,
):
prefs = self.mw.col.get_preferences()
prefs.scheduling.new_timezone = False
self.mw.col.set_preferences(prefs)
2021-03-26 04:48:26 +01:00
showInfo(tr.scheduling_update_done())
self.refresh()