summaryrefslogtreecommitdiff
path: root/raphodo/errorlog.py
diff options
context:
space:
mode:
Diffstat (limited to 'raphodo/errorlog.py')
-rw-r--r--raphodo/errorlog.py596
1 files changed, 596 insertions, 0 deletions
diff --git a/raphodo/errorlog.py b/raphodo/errorlog.py
new file mode 100644
index 0000000..7651d33
--- /dev/null
+++ b/raphodo/errorlog.py
@@ -0,0 +1,596 @@
+#!/usr/bin/env python3
+
+# Copyright (C) 2017 Damon Lynch <damonlynch@gmail.com>
+
+# This file is part of Rapid Photo Downloader.
+#
+# Rapid Photo Downloader is free software: you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# Rapid Photo Downloader is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Rapid Photo Downloader. If not,
+# see <http://www.gnu.org/licenses/>.
+
+"""
+Error log window for Rapid Photo Downloader
+"""
+
+__author__ = 'Damon Lynch'
+__copyright__ = "Copyright 2017, Damon Lynch"
+
+import logging
+import shlex
+import subprocess
+import math
+from collections import deque, namedtuple
+from typing import Optional
+from gettext import gettext as _
+import re
+from html import escape
+
+from PyQt5.QtWidgets import (
+ QTextEdit, QDialog, QDialogButtonBox, QLineEdit, QVBoxLayout, QHBoxLayout, QApplication,
+ QPushButton, QLabel, QTextBrowser, QStyle
+)
+from PyQt5.QtGui import (
+ QPalette, QIcon, QFontMetrics, QFont, QColor, QKeyEvent, QKeySequence, QTextDocument,
+ QTextCursor, QPaintEvent, QPainter, QPen, QMouseEvent, QShowEvent
+)
+from PyQt5.QtCore import Qt, pyqtSlot, QSize, QUrl, QTimer, QRect, pyqtSignal, QEvent
+
+import raphodo.qrc_resources as qrc_resources
+from raphodo.constants import ErrorType
+from raphodo.rpdfile import RPDFile
+from raphodo.problemnotification import Problem, Problems
+
+# ErrorLogMessage = namedtuple('ErrorLogMessage', 'title body name uri')
+
+
+class QFindLineEdit(QLineEdit):
+ """
+ LineEdit to be used for search, as in Firefox in page search.
+ """
+
+
+ def __init__(self, find_text='', parent=None) -> None:
+ super().__init__(parent=parent)
+ if not find_text:
+ self.find_text = _('Find')
+ else:
+ self.find_text = find_text
+
+ self.noTextPalette = QPalette()
+ self.noTextPalette.setColor(QPalette.Text, Qt.gray)
+
+ self.setEmptyState()
+
+ self.cursorPositionChanged.connect(self.onCursorPositionChanged)
+ self.textEdited.connect(self.onTextEdited)
+
+ def setEmptyState(self) -> None:
+ self.empty = True
+ self.setText(self.find_text)
+ self.setCursorPosition(0)
+ self.setPalette(self.noTextPalette)
+
+ @pyqtSlot(str)
+ def onTextEdited(self, text: str) -> None:
+ if not text:
+ self.setEmptyState()
+ elif self.empty:
+ self.empty = False
+ self.setPalette(QPalette())
+ self.setText(text[:-len(self.find_text)])
+
+ @pyqtSlot(int, int)
+ def onCursorPositionChanged(self, old: int, new: int) -> None:
+ if self.empty:
+ self.blockSignals(True)
+ self.setCursorPosition(0)
+ self.blockSignals(False)
+
+ def getText(self) -> str:
+ if self.empty:
+ return ''
+ else:
+ return self.text()
+
+
+class ErrorReport(QDialog):
+ """
+ Display error messages from the download in a dialog.
+
+ Search/find feature is live, like Firefox. However it's pretty slow
+ with a large amount of data, so don't initiate a new search each
+ and every time data is appended to the log window. Instead, if a search
+ is active, wait for one second after text has been appended before
+ doing the search.
+ """
+
+ dialogShown = pyqtSignal()
+ dialogActivated = pyqtSignal()
+
+ def __init__(self, rapidApp, parent=None) -> None:
+ super().__init__(parent=parent)
+
+ self.uris = []
+ self.get_href = re.compile('<a href="?\'?([^"\'>]*)')
+
+ self.setModal(False)
+ self.setSizeGripEnabled(True)
+
+ self.search_pending = False
+ self.add_queue = deque()
+
+ self.rapidApp = rapidApp
+
+ layout = QVBoxLayout()
+ self.setWindowTitle(_('Error Reports - Rapid Photo Downloader'))
+
+ self.log = QTextBrowser()
+ self.log.setReadOnly(True)
+
+ sheet = """
+ h1 {
+ font-size: large;
+ font-weight: bold;
+ }
+ """
+
+ document = self.log.document() # type: QTextDocument
+ document.setDefaultStyleSheet(sheet)
+ # document.setIndentWidth(QFontMetrics(QFont()).boundingRect('200').width())
+
+ self.highlightColor = QColor('#cb1dfa')
+ self.textHighlightColor = QColor(Qt.white)
+
+ self.noFindPalette = QPalette()
+ self.noFindPalette.setColor(QPalette.WindowText, QPalette().color(QPalette.Mid))
+ self.foundPalette = QPalette()
+ self.foundPalette.setColor(QPalette.WindowText, QPalette().color(QPalette.WindowText))
+
+ self.find_cursors = []
+ self.current_find_index = -1
+
+ self.log.anchorClicked.connect(self.anchorClicked)
+ self.log.setOpenLinks(False)
+
+ self.defaultFont = QFont()
+ self.defaultFont.setPointSize(QFont().pointSize() - 1)
+ self.log.setFont(self.defaultFont)
+ self.log.textChanged.connect(self.textChanged)
+
+ message = _('Find in reports')
+ self.find = QFindLineEdit(find_text=message)
+ self.find.textEdited.connect(self.onFindChanged)
+ style = self.find.style() # type: QStyle
+ frame_width = style.pixelMetric(QStyle.PM_DefaultFrameWidth)
+ button_margin = style.pixelMetric(QStyle.PM_ButtonMargin)
+ spacing = (frame_width + button_margin) * 2 + 8
+
+ self.find.setMinimumWidth(QFontMetrics(QFont()).boundingRect(message).width() + spacing)
+
+ font_height = QFontMetrics(self.font()).height()
+ size = QSize(font_height, font_height)
+
+ self.up = QPushButton()
+ self.up.setIcon(QIcon(':/icons/up.svg'))
+ self.up.setIconSize(size)
+ self.up.clicked.connect(self.upClicked)
+ self.up.setToolTip(_('Find the previous occurrence of the phrase'))
+ self.down = QPushButton()
+ self.down.setIcon(QIcon(':/icons/down.svg'))
+ self.down.setIconSize(size)
+ self.down.clicked.connect(self.downClicked)
+ self.down.setToolTip(_('Find the next occurrence of the phrase'))
+
+ self.highlightAll = QPushButton(_('&Highlight All'))
+ self.highlightAll.setToolTip(_('Highlight all occurrences of the phrase'))
+ self.matchCase = QPushButton(_('&Match Case'))
+ self.matchCase.setToolTip(_('Search with case sensitivity'))
+ self.wholeWords = QPushButton(_('&Whole Words'))
+ self.wholeWords.setToolTip(_('Search whole words only'))
+ for widget in (self.highlightAll, self.matchCase, self.wholeWords):
+ widget.setCheckable(True)
+ widget.setFlat(True)
+ self.highlightAll.toggled.connect(self.highlightAllToggled)
+ self.matchCase.toggled.connect(self.matchCaseToggled)
+ self.wholeWords.toggled.connect(self.wholeWordsToggled)
+
+ self.findResults = QLabel()
+ self.findResults.setMinimumWidth(QFontMetrics(QFont()).boundingRect(
+ _('%s of %s matches') % (1000, 1000)).width() + spacing)
+
+ findLayout = QHBoxLayout()
+ findLayout.setSpacing(0)
+ spacing = 8
+ findLayout.addWidget(self.find)
+ findLayout.addWidget(self.up)
+ findLayout.addWidget(self.down)
+ findLayout.addSpacing(spacing)
+ findLayout.addWidget(self.highlightAll)
+ findLayout.addSpacing(spacing)
+ findLayout.addWidget(self.matchCase)
+ findLayout.addSpacing(spacing)
+ findLayout.addWidget(self.wholeWords)
+ findLayout.addSpacing(spacing)
+ findLayout.addWidget(self.findResults)
+
+
+ buttons = QDialogButtonBox(QDialogButtonBox.Close)
+ self.clear = buttons.addButton(_('Clear'), QDialogButtonBox.ActionRole) # type: QPushButton
+ buttons.rejected.connect(self.reject)
+ self.clear.clicked.connect(self.clearClicked)
+ self.clear.setEnabled(False)
+
+ layout.addWidget(self.log)
+ layout.addLayout(findLayout)
+ layout.addSpacing(6)
+ layout.addWidget(buttons)
+
+ self.setLayout(layout)
+
+ self.onFindChanged('')
+
+ self.icon_lookup = {
+ ErrorType.warning: ':/report-warning.png',
+ ErrorType.serious_error: ':/report-error.png',
+ ErrorType.critical_error: ':/report-critical.png'
+ }
+
+ @pyqtSlot()
+ def textChanged(self) -> None:
+ self.clear.setEnabled(bool(self.log.document().characterCount()))
+
+ def _makeFind(self, back: bool=False) -> int:
+ flags = QTextDocument.FindFlags()
+ if self.matchCase.isChecked():
+ flags |= QTextDocument.FindCaseSensitively
+ if self.wholeWords.isChecked():
+ flags |= QTextDocument.FindWholeWords
+ if back:
+ flags |= QTextDocument.FindBackward
+ return flags
+
+ def _clearSearch(self) -> None:
+ cursor = self.log.textCursor() # type: QTextCursor
+ if cursor.hasSelection():
+ cursor.clearSelection()
+ self.log.setTextCursor(cursor)
+ self.find_cursors = []
+ self.log.setExtraSelections([])
+
+ @pyqtSlot()
+ def _doFind(self) -> None:
+ """
+ Do the find / search.
+
+ If text needs to be appended, delay the search for one second.
+ """
+
+ if self.add_queue:
+ while self.add_queue:
+ self._addProblems(problems=self.add_queue.popleft())
+ QTimer.singleShot(1000, self._doFind)
+ return
+
+ cursor = self.log.textCursor() # type: QTextCursor
+ text = self.find.getText()
+ highlight = self.highlightAll.isChecked()
+
+ if self.find.empty or not text:
+ self._clearSearch()
+ self.findResults.setText('')
+ return
+
+ initial_position = cursor.selectionStart() # type: int
+
+ self.log.moveCursor(QTextCursor.Start)
+
+ flags = self._makeFind()
+ extraSelections = deque()
+
+ count = 0
+ index = None
+ self.find_cursors = []
+
+ while self.log.find(text, flags):
+ cursor = self.log.textCursor() # type: QTextCursor
+ self.find_cursors.append(cursor)
+
+ if index is None and cursor.selectionStart() >= initial_position:
+ index = count
+ count += 1
+
+ if highlight:
+ extra = QTextEdit.ExtraSelection()
+ extra.format.setBackground(self.highlightColor)
+ extra.format.setForeground(self.textHighlightColor)
+ extra.cursor = cursor
+ extraSelections.append(extra)
+
+ self.log.setExtraSelections(extraSelections)
+
+ if index is None:
+ index = len(self.find_cursors) - 1
+
+ if not self.find_cursors:
+ cursor.setPosition(initial_position)
+ self.log.setTextCursor(cursor)
+ if not self.find.empty:
+ self.findResults.setText(_('Phrase not found'))
+ self.findResults.setPalette(self.noFindPalette)
+
+ else:
+ self.goToMatch(index=index)
+
+ self.search_pending = False
+
+ def goToMatch(self, index: int) -> None:
+ if self.find_cursors:
+ cursor = self.find_cursors[index]
+ self.current_find_index = index
+ self.log.setTextCursor(cursor)
+ self.findResults.setText(_('%s of %s matches') % (index + 1, len(self.find_cursors)))
+ self.findResults.setPalette(self.foundPalette)
+
+ @pyqtSlot(bool)
+ def upClicked(self, checked: bool) -> None:
+ if self.current_find_index >= 0:
+ if self.current_find_index == 0:
+ index = len(self.find_cursors) - 1
+ else:
+ index = self.current_find_index - 1
+ self.goToMatch(index=index)
+
+ @pyqtSlot(bool)
+ def downClicked(self, checked: bool) -> None:
+ if self.current_find_index >= 0:
+ if self.current_find_index == len(self.find_cursors) - 1:
+ index = 0
+ else:
+ index = self.current_find_index + 1
+ self.goToMatch(index=index)
+
+ @pyqtSlot(str)
+ def onFindChanged(self, text: str) -> None:
+ self.up.setEnabled(not self.find.empty)
+ self.down.setEnabled(not self.find.empty)
+
+ self._doFind()
+
+ @pyqtSlot(bool)
+ def highlightAllToggled(self, toggled: bool) -> None:
+ if self.find_cursors:
+ extraSelections = deque()
+ if self.highlightAll.isChecked():
+ for cursor in self.find_cursors:
+ extra = QTextEdit.ExtraSelection()
+ extra.format.setBackground(self.highlightColor)
+ extra.format.setForeground(self.textHighlightColor)
+ extra.cursor = cursor
+ extraSelections.append(extra)
+ self.log.setExtraSelections(extraSelections)
+
+ @pyqtSlot(bool)
+ def matchCaseToggled(self, toggled: bool) -> None:
+ self._doFind()
+
+ @pyqtSlot(bool)
+ def wholeWordsToggled(self, toggled: bool) -> None:
+ self._doFind()
+
+ @pyqtSlot(bool)
+ def clearClicked(self, toggled: bool) -> None:
+ self.log.clear()
+ self.clear.setEnabled(False)
+ self._doFind()
+
+ @pyqtSlot(QUrl)
+ def anchorClicked(self, url: QUrl) -> None:
+ if self.rapidApp.file_manager:
+ # see documentation for self._saveUrls()
+ fake_uri = url.url()
+ index = int(fake_uri[fake_uri.find('///') + 3:])
+ uri = self.uris[index]
+
+ cmd = '{} {}'.format(self.rapidApp.file_manager, uri)
+ logging.debug("Launching: %s", cmd)
+ args = shlex.split(cmd)
+ subprocess.Popen(args)
+
+ def _saveUrls(self, text: str) -> str:
+ """
+ Sadly QTextBrowser uses QUrl, which doesn't understand the kind of URIs
+ used by Gnome. It totally mangles them, in fact.
+
+ So solution is to substitute in a dummy uri and then
+ replace it in self.anchorClicked() when the user clicks on it
+ """
+
+ anchor_start = '<a href="'
+ anchor_end = '</a>'
+
+ start = text.find(anchor_start)
+ if start < 0:
+ return text
+ new_text = text[:start]
+ while start >= 0:
+ href_end = text.find('">', start + 9)
+ href = text[start + 9:href_end]
+ end = text.find(anchor_end, href_end + 2)
+ next_start = text.find(anchor_start, end + 4)
+ if next_start >= end + 4:
+ extra_text = text[end + 4:next_start]
+ else:
+ extra_text = text[end + 4:]
+ new_text = '{}<a href="file:///{}">{}</a>{}'.format(
+ new_text, len(self.uris), text[href_end + 2:end], extra_text
+ )
+ self.uris.append(href)
+ start = next_start
+
+ return new_text
+
+ def _getBody(self, problem: Problem) -> str:
+ """
+ Get the body (subject) of the problem, and any details
+ """
+
+ line = self._saveUrls(problem.body)
+
+ if len(problem.details) == 1:
+ line = '{}<br><i>{}</i>'.format(line, self._saveUrls(problem.details[0]))
+ elif len(problem.details) > 1:
+ for detail in problem.details:
+ line = '{}<br><i>{}</i>'.format(line, self._saveUrls(detail))
+
+ return line
+
+ def _addProblems(self, problems: Problems) -> None:
+ """
+ Add problems to the log window
+ """
+
+ title = self._saveUrls(problems.title)
+ html = '<h1>{}</h1><p></p>'.format(title)
+ html = '{}<table>'.format(html)
+ for problem in problems:
+ line = self._getBody(problem=problem)
+ icon = self.icon_lookup[problem.severity]
+ icon = '<img src="{}" height=16 width=16>'.format(icon)
+ html = '{}<tr><td width=32 align=center>{}</td><td style="padding-bottom: 6px;">' \
+ '{}</td></tr>'.format(html, icon, line)
+ html = '{}</table>'.format(html)
+
+ html = '{}<p></p><p></p>'.format(html)
+ self.log.append(html)
+
+ def addProblems(self, problems: Problems) -> None:
+ if not self.find.empty and self.find_cursors:
+ self._clearSearch()
+
+ if not self.find.empty and self.search_pending:
+ self.add_queue.append(problems)
+ else:
+ self._addProblems(problems=problems)
+
+ if not self.find.empty and not self.search_pending:
+ self.search_pending = True
+ self.findResults.setText(_('Search pending...'))
+ self.findResults.setPalette(self.noFindPalette)
+ QTimer.singleShot(1000, self._doFind)
+
+ def keyPressEvent(self, event: QKeyEvent) -> None:
+ if event.matches(QKeySequence.Find):
+ self.find.setFocus()
+ else:
+ super().keyPressEvent(event)
+
+ @pyqtSlot()
+ def activate(self) -> None:
+ self.setVisible(True)
+ self.activateWindow()
+ self.raise_()
+
+ def showEvent(self, event: QShowEvent) -> None:
+ super().showEvent(event)
+ self.dialogShown.emit()
+
+ def changeEvent(self, event: QEvent) -> None:
+ if event.type() == QEvent.ActivationChange and self.isActiveWindow():
+ self.dialogActivated.emit()
+ super().changeEvent(event)
+
+
+class SpeechBubble(QLabel):
+
+ """
+ Display a speech bubble with a counter in it, that when clicked
+ emits a signal and resets.
+
+ Bubble displayed only when counter is > 0.
+ """
+
+ clicked = pyqtSignal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.rapidApp = parent
+ self.image = QIcon(':/speech-bubble.svg')
+ self._count = 0
+ self.fillColor = QPalette().color(QPalette.Window)
+ self.counterFont = QFont()
+ self.counterFont.setPointSize(QFont().pointSize() - 1)
+ self.custom_height = max(math.ceil(QFontMetrics(self.counterFont).height() * 1.7), 24)
+ self.counterPen = QPen(QColor(Qt.white))
+ self.setStyleSheet("QLabel {border: 0px;}")
+ self.click_tooltip = _('The number of new entries added to the Error Report since it was '
+ 'last open. Click to open the Error Report.')
+
+ @property
+ def count(self) -> int:
+ return self._count
+
+ @count.setter
+ def count(self, value) -> None:
+ self._count = value
+ if value > 0:
+ self.setToolTip(self.click_tooltip)
+ self.update()
+
+ def incrementCounter(self, increment: int=1) -> None:
+ self._count += increment
+ self.setToolTip(self.click_tooltip)
+ self.update()
+
+ def paintEvent(self, event: QPaintEvent ):
+
+ painter = QPainter()
+ painter.begin(self)
+
+ height = self.height()
+
+ rect = self.rect() # type: QRect
+ if not self._count:
+ painter.fillRect(rect, self.fillColor)
+ else:
+ painter.drawPixmap(0, 0, height, height, self.image.pixmap(height, height))
+ painter.setFont(self.counterFont)
+ painter.setPen(self.counterPen)
+ if self._count > 9:
+ value = '9+'
+ else:
+ value = str(self._count)
+ painter.drawText(rect, Qt.AlignCenter, value)
+ painter.end()
+
+ def sizeHint(self) -> QSize:
+ return QSize(self.custom_height, self.custom_height)
+
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ self.clicked.emit()
+ self.reset()
+
+ @pyqtSlot()
+ def reset(self) -> None:
+ self.count = 0
+ self.setToolTip('')
+
+
+if __name__ == '__main__':
+
+ # Application development test code:
+
+ app = QApplication([])
+
+ log = ErrorReport(None)
+ log.show()
+ app.exec_() \ No newline at end of file