Friendly logging for end users (#859)

* Changed default logging behaviour

We now log by default to a timestamped file in $datadir/logs/. No longer
shall restarting Manuskript after a crash wipe a very useful logfile.

Logs older than 35 days in the $datadir/logs/ directory are pruned
during startup. In case of subtle corruption detected a few weeks after
the fact, relevant logs might still exist to explain what had happened...
yet it does not come at the cost of infinitely gobbling up a users
storage space, either.

The --logfile (-L) argument can now utilize strftime() specifiers. A
special modifier %# is also supported which will insert the process id.
Besides being an added factor of uniqueness for a filename, it can also
be relevant to help identify the log file belonging to a misbehaving
Manuskript process.

* Added support-related items to Help menu

The 'Technical Support' item should lead to a landing page that will
guide the user to the most efficient way to resolve their problem.
How to report bugs and submit logs would be one of those.

The 'Locate Log File' item should open a file manager window with the
logfile of this session highlighted. Because Manuskript is still writing
to it, we first remind them of its limited use until Manuskript is
closed.

This approach was chosen because users might want to locate the file
prior to reproducing a bug, or because they'd like to look at other logs
from previous sessions.

* Updated translation files and added german translation

Co-authored-by: TheJackiMonster <thejackimonster@gmail.com>
This commit is contained in:
worstje 2021-04-13 13:32:46 +02:00 committed by GitHub
parent 0d67c15c9c
commit b2817b5f08
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 9150 additions and 7756 deletions

File diff suppressed because it is too large Load diff

Binary file not shown.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,8 @@ import sys
import pathlib
from random import *
from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QRegExp, QDir
from PyQt5.QtCore import QUrl, QTimer
from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QProcess, QRegExp
from PyQt5.QtCore import QDir, QUrl, QTimer
from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap
from PyQt5.QtGui import QDesktopServices
from PyQt5.QtWidgets import qApp, QFileDialog
@ -540,5 +540,41 @@ def getGitRevisionAsString(base_path, short=False):
LOGGER.warning("Failed to obtain Git revision: %s", e)
return "#ERROR"
def showInFolder(path, open_file_as_fallback=False):
'''
Show a file or folder in explorer/finder, highlighting it where possible.
Source: https://stackoverflow.com/a/46019091/3388962
'''
path = os.path.abspath(path)
dirPath = path if os.path.isdir(path) else os.path.dirname(path)
if sys.platform == 'win32':
args = []
args.append('/select,')
args.append(QDir.toNativeSeparators(path))
if QProcess.startDetached('explorer', args):
return True
elif sys.platform == 'darwin':
args = []
args.append('-e')
args.append('tell application "Finder"')
args.append('-e')
args.append('activate')
args.append('-e')
args.append('select POSIX file "%s"' % path)
args.append('-e')
args.append('end tell')
args.append('-e')
args.append('return')
if not QProcess.execute('/usr/bin/osascript', args):
return True
#if not QtCore.QProcess.execute('/usr/bin/open', [dirPath]):
# return
# TODO: Linux is not implemented. It has many file managers (nautilus, xdg-open, etc.)
# each of which needs special ways to highlight a file in a file manager window.
# Fallback.
return QDesktopServices.openUrl(QUrl(path if open_file_as_fallback else dirPath))
# Spellchecker loads writablePath from this file, so we need to load it after they get defined
from manuskript.functions.spellchecker import Spellchecker

View file

@ -6,6 +6,7 @@
import os
import sys
import time
import logging
import pathlib
@ -48,6 +49,62 @@ def setUp(console_level=logging.WARN):
LOGGER.debug("Logging to STDERR.")
def getDefaultLogFile():
"""Returns a filename to log to inside {datadir}/logs/.
It also prunes old logs so that we do not hog disk space excessively over time.
"""
# Ensure logs directory exists.
logsPath = os.path.join(writablePath(), "logs")
os.makedirs(logsPath, exist_ok=True)
# Prune irrelevant log files. They are only kept for 35 days.
try: # Guard against os.scandir() in the name of paranoia.
now = time.time()
with os.scandir(logsPath) as it:
for f in it:
try: # Avoid triggering outer try-except inside loop.
if f.is_dir():
continue # If a subdirectory exists for whatever reason, don't touch it.
if (now - f.stat().st_ctime) // (24 * 3600) >= 35:
os.remove(f)
except OSError:
continue # Fail silently, but make sure we check other files.
except OSError:
pass # Fail silently. Don't explode and prevent Manuskript from starting.
return os.path.join(logsPath, "%Y-%m-%d_%H-%M-%S_manuskript#%#.log")
def formatLogName(formatString, pid=None, now=None):
"""A minor hack on top of `strftime()` to support an identifier for the process ID.
We want to support this in case some genius manages to start two manuskript processes
during the exact same second, causing a conflict in log filenames.
Additionally, there is a tiny chance that the pid could actually end up relevant when
observing strange behaviour with a Manuskript process but having multiple instances open.
"""
if pid == None:
pid = os.getpid()
if now == None:
now = time.localtime()
# Replace %# that is NOT preceded by %. Although this is not a perfect solution,
# it is good enough because it is unlikely anyone would want to format '%pid'.
lidx = 0
while True: # This could be neater with the := operator of Python 3.8 ...
fidx = formatString.find("%#", lidx)
if fidx == -1:
break
elif (fidx == 0) or (formatString[fidx-1] != "%"):
formatString = formatString[:fidx] + str(pid) + formatString[fidx+2:]
lidx = fidx + len(str(pid)) - 2
else: # skip and avoid endless loop
lidx = fidx + 1
# Finally apply strftime normally.
return time.strftime(formatString, now)
def logToFile(file_level=logging.DEBUG, logfile=None):
"""Sets up the FileHandler that logs to a file.
@ -57,7 +114,9 @@ def logToFile(file_level=logging.DEBUG, logfile=None):
To log file: >DEBUG, timestamped. (All the details.)"""
if logfile is None:
logfile = os.path.join(writablePath(), "manuskript.log")
logfile = getDefaultLogFile()
logfile = formatLogName(logfile)
# Log with extreme prejudice; everything goes to the log file.
# Because Qt gave me a megabyte-sized logfile while testing, it
@ -77,6 +136,16 @@ def logToFile(file_level=logging.DEBUG, logfile=None):
except Exception as ex:
LOGGER.warning("Cannot log to file '%s'. Reason: %s", logfile, ex)
def getLogFilePath():
"""Extracts a filename we are logging to from the first FileHandler we find."""
root_logger = logging.getLogger()
for handler in root_logger.handlers:
if isinstance(handler, logging.FileHandler):
return handler.baseFilename
return None
# Log uncaught and unraisable exceptions.
# Uncaught exceptions trigger moments before a thread is terminated due to

View file

@ -13,9 +13,10 @@ from PyQt5.QtWidgets import QMainWindow, QHeaderView, qApp, QMenu, QActionGroup,
from manuskript import settings
from manuskript.enums import Character, PlotStep, Plot, World, Outline
from manuskript.functions import wordCount, appPath, findWidgetsOfClass
from manuskript.functions import wordCount, appPath, findWidgetsOfClass, openURL, showInFolder
import manuskript.functions as F
from manuskript import loadSave
from manuskript.logging import getLogFilePath
from manuskript.models.characterModel import characterModel
from manuskript.models import outlineModel
from manuskript.models.plotModel import plotModel
@ -179,6 +180,8 @@ class MainWindow(QMainWindow, Ui_MainWindow):
# Main Menu:: Tool
self.actToolFrequency.triggered.connect(self.frequencyAnalyzer)
self.actSupport.triggered.connect(self.support)
self.actLocateLog.triggered.connect(self.locateLogFile)
self.actAbout.triggered.connect(self.about)
self.makeUIConnections()
@ -1179,6 +1182,50 @@ class MainWindow(QMainWindow, Ui_MainWindow):
r2 = self.geometry()
win.move(r2.center() - QPoint(r.width()/2, r.height()/2))
def support(self):
openURL("https://github.com/olivierkes/manuskript/wiki/Technical-Support")
def locateLogFile(self):
logfile = getLogFilePath()
# Make sure we are even logging to a file.
if not logfile:
QMessageBox(QMessageBox.Information,
self.tr("Sorry!"),
"<p><b>" +
self.tr("This session is not being logged.") +
"</b></p>",
QMessageBox.Ok).exec()
return
# Remind user that log files are at their best once they are complete.
msg = QMessageBox(QMessageBox.Information,
self.tr("A log file is a Work in Progress!"),
"<p><b>" +
self.tr("The log file \"{}\" will continue to be written to until Manuskript is closed.").format(os.path.basename(logfile)) +
"</b></p>" +
"<p>" +
self.tr("It will now be displayed in your file manager, but is of limited use until you close Manuskript.") +
"</p>",
QMessageBox.Ok)
ret = msg.exec()
# Open the filemanager.
if ret == QMessageBox.Ok:
if not showInFolder(logfile):
# If everything convenient fails, at least make sure the user can browse to its location manually.
QMessageBox(QMessageBox.Critical,
self.tr("Error!"),
"<p><b>" +
self.tr("An error was encountered while trying to show the log file below in your file manager.") +
"</b></p>" +
"<p>" +
logfile +
"</p>",
QMessageBox.Ok).exec()
def about(self):
self.dialog = aboutDialog(mw=self)
self.dialog.setFixedSize(self.dialog.size())

View file

@ -381,7 +381,7 @@ class Ui_MainWindow(object):
self.scrollAreaPersoInfos.setAlignment(QtCore.Qt.AlignLeading|QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
self.scrollAreaPersoInfos.setObjectName("scrollAreaPersoInfos")
self.scrollAreaPersoInfosWidget = QtWidgets.QWidget()
self.scrollAreaPersoInfosWidget.setGeometry(QtCore.QRect(0, 0, 453, 695))
self.scrollAreaPersoInfosWidget.setGeometry(QtCore.QRect(0, 0, 429, 719))
self.scrollAreaPersoInfosWidget.setObjectName("scrollAreaPersoInfosWidget")
self.formLayout_8 = QtWidgets.QFormLayout(self.scrollAreaPersoInfosWidget)
self.formLayout_8.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow)
@ -757,7 +757,7 @@ class Ui_MainWindow(object):
self.treeWorld.setRootIsDecorated(False)
self.treeWorld.setObjectName("treeWorld")
self.treeWorld.header().setVisible(False)
self.treeWorld.header().setDefaultSectionSize(25)
self.treeWorld.header().setDefaultSectionSize(35)
self.verticalLayout_32.addWidget(self.treeWorld)
self.horizontalLayout_19 = QtWidgets.QHBoxLayout()
self.horizontalLayout_19.setObjectName("horizontalLayout_19")
@ -1042,7 +1042,7 @@ class Ui_MainWindow(object):
self.horizontalLayout_2.addWidget(self.stack)
MainWindow.setCentralWidget(self.centralwidget)
self.menubar = QtWidgets.QMenuBar(MainWindow)
self.menubar.setGeometry(QtCore.QRect(0, 0, 1112, 24))
self.menubar.setGeometry(QtCore.QRect(0, 0, 1112, 21))
self.menubar.setObjectName("menubar")
self.menuFile = QtWidgets.QMenu(self.menubar)
self.menuFile.setObjectName("menuFile")
@ -1287,6 +1287,10 @@ class Ui_MainWindow(object):
icon = QtGui.QIcon.fromTheme("edit-find")
self.actSearch.setIcon(icon)
self.actSearch.setObjectName("actSearch")
self.actSupport = QtWidgets.QAction(MainWindow)
self.actSupport.setObjectName("actSupport")
self.actLocateLog = QtWidgets.QAction(MainWindow)
self.actLocateLog.setObjectName("actLocateLog")
self.menuFile.addAction(self.actOpen)
self.menuFile.addAction(self.menuRecents.menuAction())
self.menuFile.addAction(self.actSave)
@ -1298,6 +1302,10 @@ class Ui_MainWindow(object):
self.menuFile.addSeparator()
self.menuFile.addAction(self.actQuit)
self.menuHelp.addAction(self.actShowHelp)
self.menuHelp.addSeparator()
self.menuHelp.addAction(self.actSupport)
self.menuHelp.addAction(self.actLocateLog)
self.menuHelp.addSeparator()
self.menuHelp.addAction(self.actAbout)
self.menuTools.addAction(self.actSpellcheck)
self.menuTools.addAction(self.actToolFrequency)
@ -1656,6 +1664,13 @@ class Ui_MainWindow(object):
self.actFormatBlockquote.setText(_translate("MainWindow", "B&lockquote"))
self.actSearch.setText(_translate("MainWindow", "Search"))
self.actSearch.setShortcut(_translate("MainWindow", "Ctrl+F"))
self.actSupport.setText(_translate("MainWindow", "&Technical Support"))
self.actSupport.setToolTip(_translate("MainWindow", "How to obtain technical support for Manuskript."))
self.actSupport.setShortcut(_translate("MainWindow", "F1"))
self.actLocateLog.setText(_translate("MainWindow", "&Locate log file..."))
self.actLocateLog.setIconText(_translate("MainWindow", "Locate log file"))
self.actLocateLog.setToolTip(_translate("MainWindow", "Locate the diagnostic log file used for this session."))
self.actLocateLog.setShortcut(_translate("MainWindow", "Shift+F1"))
from manuskript.ui.cheatSheet import cheatSheet
from manuskript.ui.editors.mainEditor import mainEditor
from manuskript.ui.search import search

View file

@ -815,8 +815,8 @@
<rect>
<x>0</x>
<y>0</y>
<width>453</width>
<height>695</height>
<width>429</width>
<height>719</height>
</rect>
</property>
<layout class="QFormLayout" name="formLayout_8">
@ -1567,7 +1567,7 @@
<bool>false</bool>
</attribute>
<attribute name="headerDefaultSectionSize">
<number>25</number>
<number>35</number>
</attribute>
</widget>
</item>
@ -2115,7 +2115,7 @@
<x>0</x>
<y>0</y>
<width>1112</width>
<height>24</height>
<height>21</height>
</rect>
</property>
<widget class="QMenu" name="menuFile">
@ -2147,6 +2147,10 @@
<string>&amp;Help</string>
</property>
<addaction name="actShowHelp"/>
<addaction name="separator"/>
<addaction name="actSupport"/>
<addaction name="actLocateLog"/>
<addaction name="separator"/>
<addaction name="actAbout"/>
</widget>
<widget class="QMenu" name="menuTools">
@ -2841,7 +2845,8 @@
</action>
<action name="actSearch">
<property name="icon">
<iconset theme="edit-find"/>
<iconset theme="edit-find">
<normaloff>.</normaloff>.</iconset>
</property>
<property name="text">
<string>Search</string>
@ -2850,6 +2855,31 @@
<string>Ctrl+F</string>
</property>
</action>
<action name="actSupport">
<property name="text">
<string>&amp;Technical Support</string>
</property>
<property name="toolTip">
<string>How to obtain technical support for Manuskript.</string>
</property>
<property name="shortcut">
<string>F1</string>
</property>
</action>
<action name="actLocateLog">
<property name="text">
<string>&amp;Locate log file...</string>
</property>
<property name="iconText">
<string>Locate log file</string>
</property>
<property name="toolTip">
<string>Locate the diagnostic log file used for this session.</string>
</property>
<property name="shortcut">
<string>Shift+F1</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>