Rework error dialog

- Hide traceback
- Include full add-on info in 'copy debug info' button, like about
screen
- Link to troubleshooting page
- Use non-modal pop-up in the common case, to avoid potential conflicts
with other modals.

Closes #2830
This commit is contained in:
Damien Elmes 2023-11-29 10:25:32 +10:00
parent e5170f341b
commit 063b6f60fd
6 changed files with 121 additions and 104 deletions

View file

@ -1,4 +1,10 @@
-errors-support-site = [support site](https://help.ankiweb.net) -errors-support-site = [support site](https://help.ankiweb.net)
errors-standard-popup2 =
Anki encountered a problem. Please follow the troubleshooting steps.
errors-may-be-addon = The problem may be caused by an add-on.
errors-troubleshooting-button = Troubleshooting
errors-copy-debug-info-button = Copy Debug Info
errors-copied-to-clipboard = Copied to clipboard
errors-standard-popup = errors-standard-popup =
# Error # Error

View file

@ -42,6 +42,7 @@ message HelpPageLinkRequest {
CARD_TYPE_MISSING_CLOZE = 20; CARD_TYPE_MISSING_CLOZE = 20;
CARD_TYPE_EXTRANEOUS_CLOZE = 21; CARD_TYPE_EXTRANEOUS_CLOZE = 21;
CARD_TYPE_TEMPLATE_ERROR = 22; CARD_TYPE_TEMPLATE_ERROR = 22;
TROUBLESHOOTING = 23;
} }
HelpPage page = 1; HelpPage page = 1;
} }

View file

@ -2,12 +2,11 @@
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import platform import platform
import time
import aqt.forms import aqt.forms
from anki.lang import without_unicode_isolation from anki.lang import without_unicode_isolation
from anki.utils import version_with_build from anki.utils import version_with_build
from aqt.addons import AddonManager, AddonMeta from aqt.errors import addon_debug_info
from aqt.qt import * from aqt.qt import *
from aqt.utils import disable_help_button, supportText, tooltip, tr from aqt.utils import disable_help_button, supportText, tooltip, tr
@ -33,61 +32,15 @@ def show(mw: aqt.AnkiQt) -> QDialog:
abt = aqt.forms.about.Ui_About() abt = aqt.forms.about.Ui_About()
abt.setupUi(dialog) abt.setupUi(dialog)
# Copy debug info def on_copy() -> None:
###################################################################### txt = supportText()
if mw.addonManager.dirty:
def addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str: txt += "\n" + addon_debug_info()
if addon.installed_at: QApplication.clipboard().setText(txt)
installed = time.strftime(
"%Y-%m-%dT%H:%M", time.localtime(addon.installed_at)
)
else:
installed = "0"
if addon.provided_name:
name = addon.provided_name
else:
name = "''"
user = addmgr.getConfig(addon.dir_name)
default = addmgr.addonConfigDefaults(addon.dir_name)
if user == default:
modified = "''"
else:
modified = "mod"
return f"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]"
def onCopy() -> None:
addmgr = mw.addonManager
active = []
activeids = []
inactive = []
for addon in addmgr.all_addon_meta():
if addon.enabled:
active.append(addon_fmt(addmgr, addon))
if addon.ankiweb_id():
activeids.append(addon.dir_name)
else:
inactive.append(addon_fmt(addmgr, addon))
newline = "\n"
info = f"""
{supportText()}
===Add-ons (active)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(active))}
===IDs of active AnkiWeb add-ons===
{" ".join(activeids)}
===Add-ons (inactive)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(inactive))}
"""
info = f" {' '.join(info.splitlines(True))}"
QApplication.clipboard().setText(info)
tooltip(tr.about_copied_to_clipboard(), parent=dialog) tooltip(tr.about_copied_to_clipboard(), parent=dialog)
btn = QPushButton(tr.about_copy_debug_info()) btn = QPushButton(tr.about_copy_debug_info())
qconnect(btn.clicked, onCopy) qconnect(btn.clicked, on_copy)
abt.buttonBox.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole) abt.buttonBox.addButton(btn, QDialogButtonBox.ButtonRole.ActionRole)
abt.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setFocus() abt.buttonBox.button(QDialogButtonBox.StandardButton.Ok).setFocus()

View file

@ -3,18 +3,18 @@
from __future__ import annotations from __future__ import annotations
import html
import re import re
import sys import time
import traceback
from typing import TYPE_CHECKING, Optional, TextIO, cast from typing import TYPE_CHECKING, Optional, TextIO, cast
from markdown import markdown from markdown import markdown
import aqt import aqt
from anki.collection import HelpPage
from anki.errors import BackendError, Interrupted from anki.errors import BackendError, Interrupted
from aqt.addons import AddonManager, AddonMeta
from aqt.qt import * from aqt.qt import *
from aqt.utils import showText, showWarning, supportText, tr from aqt.utils import openHelp, showWarning, supportText, tooltip, tr
if TYPE_CHECKING: if TYPE_CHECKING:
from aqt.main import AnkiQt from aqt.main import AnkiQt
@ -150,13 +150,14 @@ def is_chromium_cert_error(error: str) -> bool:
if not os.environ.get("DEBUG"): if not os.environ.get("DEBUG"):
def excepthook(etype, val, tb) -> None: # type: ignore def excepthook(etype, val, tb) -> None: # type: ignore
sys.stderr.write( sys.stderr.write("%s\n" % ("".join(traceback.format_exception(etype, val, tb))))
"Caught exception:\n%s\n"
% ("".join(traceback.format_exception(etype, val, tb)))
)
sys.excepthook = excepthook sys.excepthook = excepthook
# so we can be non-modal/non-blocking, without Python deallocating the message
# box ahead of time
_mbox: QMessageBox | None = None
class ErrorHandler(QObject): class ErrorHandler(QObject):
"Catch stderr and write into buffer." "Catch stderr and write into buffer."
@ -206,7 +207,7 @@ class ErrorHandler(QObject):
if self.fatal_error_encountered: if self.fatal_error_encountered:
# suppress follow-up errors caused by the poisoned lock # suppress follow-up errors caused by the poisoned lock
return return
error = html.escape(self.pool) error = self.pool
self.pool = "" self.pool = ""
self.mw.progress.clear() self.mw.progress.clear()
if "AbortSchemaModification" in error: if "AbortSchemaModification" in error:
@ -230,40 +231,111 @@ class ErrorHandler(QObject):
if is_chromium_cert_error(error): if is_chromium_cert_error(error):
return return
debug_text = supportText() + "\n" + error
if "PanicException" in error: if "PanicException" in error:
self.fatal_error_encountered = True self.fatal_error_encountered = True
txt = markdown( # ensure no collection-related timers like backup fire
"**A fatal error occurred, and Anki must close. Please report this message on the forums.**" self.mw.col = None
) user_text = "A fatal error occurred, and Anki must close. Please report this message on the forums."
error = f"{supportText() + self._addonText(error)}\n{error}"
elif self.mw.addonManager.dirty:
# Older translations include a link to the old discussions site; rewrite it to a newer one
message = tr.errors_addons_active_popup().replace(
"https://help.ankiweb.net/discussions/add-ons/",
"https://forums.ankiweb.net/c/add-ons/11",
)
txt = markdown(message)
error = f"{supportText() + self._addonText(error)}\n{error}"
else: else:
txt = markdown(tr.errors_standard_popup()) user_text = tr.errors_standard_popup2()
error = f"{supportText()}\n{error}" if self.mw.addonManager.dirty:
user_text += "\n\n" + self._addonText(error)
debug_text += addon_debug_info()
def show_troubleshooting():
openHelp(HelpPage.TROUBLESHOOTING)
def copy_debug_info():
QApplication.clipboard().setText(debug_text)
tooltip(tr.errors_copied_to_clipboard(), parent=_mbox)
global _mbox
_mbox = QMessageBox()
_mbox.setWindowTitle("Anki")
_mbox.setText(user_text)
_mbox.setIcon(QMessageBox.Icon.Warning)
_mbox.setTextFormat(Qt.TextFormat.PlainText)
troubleshooting = _mbox.addButton(
tr.errors_troubleshooting_button(), QMessageBox.ButtonRole.ActionRole
)
debug_info = _mbox.addButton(
tr.errors_copy_debug_info_button(), QMessageBox.ButtonRole.ActionRole
)
cancel = _mbox.addButton(QMessageBox.StandardButton.Cancel)
cancel.setText(tr.actions_close())
troubleshooting.disconnect()
troubleshooting.clicked.connect(show_troubleshooting)
debug_info.disconnect()
debug_info.clicked.connect(copy_debug_info)
# show dialog
txt = f"{txt}<div style='white-space: pre-wrap'>{error}</div>"
showText(txt, type="html", copyBtn=True)
if self.fatal_error_encountered: if self.fatal_error_encountered:
_mbox.exec()
sys.exit(1) sys.exit(1)
else:
_mbox.show()
def _addonText(self, error: str) -> str: def _addonText(self, error: str) -> str:
matches = re.findall(r"addons21(/|\\)(.*?)(/|\\)", error) matches = re.findall(r"addons21(/|\\)(.*?)(/|\\)", error)
if not matches: if not matches:
return "" return tr.errors_may_be_addon()
# reverse to list most likely suspect first, dict to deduplicate: # reverse to list most likely suspect first, dict to deduplicate:
addons = [ addons = [
aqt.mw.addonManager.addonName(i[1]) aqt.mw.addonManager.addonName(i[1])
for i in dict.fromkeys(reversed(matches)) for i in dict.fromkeys(reversed(matches))
] ]
# highlight importance of first add-on:
addons[0] = f"<b>{addons[0]}</b>"
addons_str = ", ".join(addons) addons_str = ", ".join(addons)
return f"{tr.addons_possibly_involved(addons=addons_str)}\n" return tr.addons_possibly_involved(addons=addons_str)
def addon_fmt(addmgr: AddonManager, addon: AddonMeta) -> str:
if addon.installed_at:
installed = time.strftime("%Y-%m-%dT%H:%M", time.localtime(addon.installed_at))
else:
installed = "0"
if addon.provided_name:
name = addon.provided_name
else:
name = "''"
user = addmgr.getConfig(addon.dir_name)
default = addmgr.addonConfigDefaults(addon.dir_name)
if user == default:
modified = "''"
else:
modified = "mod"
return (
f"{name} ['{addon.dir_name}', {installed}, '{addon.human_version}', {modified}]"
)
def addon_debug_info() -> str:
from aqt import mw
addmgr = mw.addonManager
active = []
activeids = []
inactive = []
for addon in addmgr.all_addon_meta():
if addon.enabled:
active.append(addon_fmt(addmgr, addon))
if addon.ankiweb_id():
activeids.append(addon.dir_name)
else:
inactive.append(addon_fmt(addmgr, addon))
newline = "\n"
info = f"""\
===Add-ons (active)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(active))}
===IDs of active AnkiWeb add-ons===
{" ".join(activeids)}
===Add-ons (inactive)===
(add-on provided name [Add-on folder, installed at, version, is config changed])
{newline.join(sorted(inactive))}
"""
return info

View file

@ -1098,39 +1098,23 @@ def add_ellipsis_to_action_label(*actions: QAction) -> None:
def supportText() -> str: def supportText() -> str:
import platform import platform
import time
from aqt import mw from aqt import mw
platname = platform.platform() platname = platform.platform()
def schedVer() -> str:
try:
if mw.col.v3_scheduler():
return "3"
else:
return str(mw.col.sched_ver())
except:
return "?"
lc = mw.pm.last_addon_update_check()
lcfmt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(lc))
return """\ return """\
Anki {} Python {} Qt {} PyQt {} Anki {} {} {}
Python {} Qt {} PyQt {}
Platform: {} Platform: {}
Flags: frz={} ao={} sv={}
Add-ons, last update check: {}
""".format( """.format(
version_with_build(), version_with_build(),
"(src)" if not getattr(sys, "frozen", False) else "",
"(ao)" if mw.addonManager.dirty else "",
platform.python_version(), platform.python_version(),
qVersion(), qVersion(),
PYQT_VERSION_STR, PYQT_VERSION_STR,
platname, platname,
getattr(sys, "frozen", False),
mw.addonManager.dirty,
schedVer(),
lcfmt,
) )

View file

@ -41,6 +41,7 @@ pub fn help_page_link_suffix(page: HelpPage) -> &'static str {
HelpPage::CardTypeExtraneousCloze => { HelpPage::CardTypeExtraneousCloze => {
"templates/errors.html#cloze-filter-outside-cloze-notetype" "templates/errors.html#cloze-filter-outside-cloze-notetype"
} }
HelpPage::Troubleshooting => "troubleshooting.html",
} }
} }