manuskript/manuskript/functions/__init__.py

585 lines
18 KiB
Python
Raw Permalink Normal View History

2015-06-05 06:22:37 +12:00
#!/usr/bin/env python
#--!-- coding: utf8 --!--
2015-06-19 01:03:16 +12:00
import os
import re
import sys
import pathlib
2016-02-07 00:34:22 +13:00
from random import *
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>
2021-04-13 23:32:46 +12:00
from PyQt5.QtCore import Qt, QRect, QStandardPaths, QObject, QProcess, QRegExp
from PyQt5.QtCore import QDir, QUrl, QTimer
2016-04-15 23:45:53 +12:00
from PyQt5.QtGui import QBrush, QIcon, QPainter, QColor, QImage, QPixmap
from PyQt5.QtGui import QDesktopServices
2019-12-22 04:42:49 +13:00
from PyQt5.QtWidgets import qApp, QFileDialog
2016-02-07 00:34:22 +13:00
from manuskript.enums import Outline
import logging
LOGGER = logging.getLogger(__name__)
2016-04-15 23:45:53 +12:00
# Used to detect multiple connections
AUC = Qt.AutoConnection | Qt.UniqueConnection
2015-07-03 23:59:41 +12:00
MW = None
2016-03-25 01:42:47 +13:00
def safeTranslate(qApp, group, text):
try:
return qApp.translate(group, text)
except:
return text
2015-06-05 06:22:37 +12:00
def wordCount(text):
2023-09-05 12:24:41 +12:00
return len(re.findall(r"\S+", re.sub(r"(<!--).+?(-->)", "", text, flags=re.DOTALL)))
2015-06-05 21:37:01 +12:00
def charCount(text, use_spaces = True):
if use_spaces:
return len(re.findall(r"[\S ]", text))
else:
return len(re.findall(r"\S", text))
validate_ok = lambda *args, **kwargs: True
def uiParse(input, default, converter, validator=validate_ok):
"""
uiParse is a utility function that intends to make it easy to convert
user input to data that falls in the range of expected values the
program is expecting to handle.
It swallows all exceptions that happen during conversion.
The validator should return True to permit the converted value.
"""
result = default
try:
result = converter(input)
except:
pass # failed to convert
# Whitelist default value in case default type differs from converter output.
if (result != default) and not validator(result):
result = default
return result
2016-03-25 01:42:47 +13:00
2015-06-05 21:37:01 +12:00
def toInt(text):
if text:
try:
return int(text)
except ValueError:
pass
return 0
2016-03-25 01:42:47 +13:00
2015-06-05 21:37:01 +12:00
def toFloat(text):
if text:
return float(text)
else:
return 0.
2016-03-25 01:42:47 +13:00
def toString(text):
2015-06-15 22:18:42 +12:00
if text in [None, "None"]:
return ""
else:
return str(text)
2016-03-25 01:42:47 +13:00
def drawProgress(painter, rect, progress, radius=0):
2017-11-14 22:36:57 +13:00
from manuskript.ui import style as S
progress = toFloat(progress) # handle invalid input (issue #561)
painter.setPen(Qt.NoPen)
2017-11-14 22:36:57 +13:00
painter.setBrush(QColor(S.base)) # "#dddddd"
painter.drawRoundedRect(rect, radius, radius)
2017-10-24 00:45:08 +13:00
2015-06-16 09:15:10 +12:00
painter.setBrush(QBrush(colorFromProgress(progress)))
2017-10-24 00:45:08 +13:00
2015-06-16 09:15:10 +12:00
r2 = QRect(rect)
r2.setWidth(int(r2.width() * min(progress, 1)))
2015-06-16 09:15:10 +12:00
painter.drawRoundedRect(r2, radius, radius)
2016-03-25 01:42:47 +13:00
2015-06-16 09:15:10 +12:00
def colorFromProgress(progress):
progress = toFloat(progress)
c1 = QColor(Qt.red)
c2 = QColor(Qt.blue)
c3 = QColor(Qt.darkGreen)
c4 = QColor("#FFA500")
2017-10-24 00:45:08 +13:00
if progress < 0.3:
2015-06-16 09:15:10 +12:00
return c1
elif progress < 0.8:
2015-06-16 09:15:10 +12:00
return c2
elif progress > 1.2:
2015-06-16 09:15:10 +12:00
return c4
else:
2015-06-16 09:15:10 +12:00
return c3
2016-03-25 01:42:47 +13:00
def mainWindow():
2015-07-03 23:59:41 +12:00
global MW
if not MW:
for i in qApp.topLevelWidgets():
if i.objectName() == "MainWindow":
MW = i
return MW
return None
else:
return MW
2015-06-10 08:05:03 +12:00
2016-03-25 01:42:47 +13:00
2015-06-10 08:05:03 +12:00
def iconColor(icon):
2016-02-07 00:34:22 +13:00
"""Returns a QRgb from a QIcon, assuming its all the same color"""
2015-06-16 02:35:23 +12:00
px = icon.pixmap(5, 5)
if px.width() != 0:
return QColor(QImage(px).pixel(2, 2))
else:
2015-06-18 03:15:13 +12:00
return QColor(Qt.transparent)
2016-03-25 01:42:47 +13:00
def iconFromColor(color):
px = QPixmap(32, 32)
px.fill(color)
return QIcon(px)
2016-03-25 01:42:47 +13:00
def iconFromColorString(string):
2015-06-16 03:24:02 +12:00
return iconFromColor(QColor(string))
2016-03-25 01:42:47 +13:00
2017-10-16 21:48:04 +13:00
def themeIcon(name):
"Returns an icon for the given name."
2017-10-24 00:45:08 +13:00
2017-10-16 21:48:04 +13:00
db = {
"character": "stock_people",
"characters": "stock_people",
"plot": "stock_shuffle",
"plots": "stock_shuffle",
"world": "emblem-web", #stock_timezone applications-internet
"outline": "gtk-index", #applications-versioncontrol
"label": "folder_color_picker",
"status": "applications-development",
"text": "view-text",
"card": "view-card",
"outline": "view-outline",
"tree": "view-list-tree",
"spelling": "tools-check-spelling"
}
2017-10-24 00:45:08 +13:00
2017-10-16 21:48:04 +13:00
if name in db:
return QIcon.fromTheme(db[name])
else:
return QIcon()
2015-06-16 03:24:02 +12:00
def randomColor(mix=None):
2016-02-07 00:34:22 +13:00
"""Generates a random color. If mix (QColor) is given, mixes the random color and mix."""
2015-06-16 03:24:02 +12:00
r = randint(0, 255)
g = randint(0, 255)
b = randint(0, 255)
2017-10-24 00:45:08 +13:00
2015-06-16 03:24:02 +12:00
if mix:
r = int((r + mix.red()) / 2)
g = int((g + mix.green()) / 2)
b = int((b + mix.blue()) / 2)
2017-10-24 00:45:08 +13:00
2015-06-16 09:15:10 +12:00
return QColor(r, g, b)
2016-03-25 01:42:47 +13:00
def mixColors(col1, col2, f=0.5):
2017-11-15 02:48:28 +13:00
fromString = False
if type(col1) == str:
fromString = True
col1 = QColor(col1)
if type(col2) == str:
col2 = QColor(col2)
f2 = 1.0 - f
r = int(col1.red() * f + col2.red() * f2)
g = int(col1.green() * f + col2.green() * f2)
b = int(col1.blue() * f + col2.blue() * f2)
2017-11-15 02:48:28 +13:00
return QColor(r, g, b) if not fromString else QColor(r, g, b).name()
2015-06-16 09:15:10 +12:00
2016-03-25 01:42:47 +13:00
2015-06-16 09:15:10 +12:00
def outlineItemColors(item):
from manuskript.ui import style as S
2016-02-07 00:34:22 +13:00
"""Takes an OutlineItem and returns a dict of colors."""
colors = {}
mw = mainWindow()
# POV
colors["POV"] = QColor(Qt.transparent)
2017-11-16 08:58:12 +13:00
POV = item.data(Outline.POV)
2017-10-15 04:11:17 +13:00
if POV == "":
col = QColor(Qt.transparent)
else:
for i in range(mw.mdlCharacter.rowCount()):
if mw.mdlCharacter.ID(i) == POV:
colors["POV"] = iconColor(mw.mdlCharacter.icon(i))
2016-02-07 00:34:22 +13:00
# Label
2017-11-16 08:58:12 +13:00
lbl = item.data(Outline.label)
2017-10-15 04:11:17 +13:00
if lbl == "":
col = QColor(Qt.transparent)
else:
col = iconColor(mw.mdlLabels.item(toInt(lbl)).icon())
2016-04-12 01:21:49 +12:00
# if col == Qt.black:
# # Don't know why, but transparent is rendered as black
# col = QColor(Qt.transparent)
2016-02-07 00:34:22 +13:00
colors["Label"] = col
# Progress
2017-11-16 08:58:12 +13:00
pg = item.data(Outline.goalPercentage)
2016-02-07 00:34:22 +13:00
colors["Progress"] = colorFromProgress(pg)
# Compile
if item.compile() in [0, "0"]:
colors["Compile"] = mixColors(QColor(S.text), QColor(S.window))
2016-02-07 00:34:22 +13:00
else:
2017-11-14 22:00:35 +13:00
colors["Compile"] = QColor(Qt.transparent) # will use default
2016-02-07 00:34:22 +13:00
return colors
2016-03-25 01:42:47 +13:00
2015-06-16 09:15:10 +12:00
def colorifyPixmap(pixmap, color):
# FIXME: ugly
p = QPainter(pixmap)
p.setCompositionMode(p.CompositionMode_Overlay)
p.fillRect(pixmap.rect(), color)
2015-06-19 01:03:16 +12:00
return pixmap
2016-03-25 01:42:47 +13:00
2015-06-19 07:28:47 +12:00
def appPath(suffix=None):
p = os.path.realpath(os.path.join(os.path.split(__file__)[0], os.path.join("..", "..")))
2015-06-19 07:28:47 +12:00
if suffix:
p = os.path.join(p, suffix)
return p
2015-06-19 01:03:16 +12:00
2016-03-25 01:42:47 +13:00
2015-06-19 07:28:47 +12:00
def writablePath(suffix=None):
2015-06-21 02:45:54 +12:00
if hasattr(QStandardPaths, "AppLocalDataLocation"):
p = QStandardPaths.writableLocation(QStandardPaths.AppLocalDataLocation)
else:
# Qt < 5.4
p = QStandardPaths.writableLocation(QStandardPaths.DataLocation)
2015-06-19 07:28:47 +12:00
if suffix:
p = os.path.join(p, suffix)
2015-06-21 02:45:54 +12:00
if not os.path.exists(p):
os.makedirs(p)
2015-06-19 07:28:47 +12:00
return p
2015-06-19 01:03:16 +12:00
2016-03-25 01:42:47 +13:00
2015-06-19 01:03:16 +12:00
def allPaths(suffix=None):
paths = []
# src directory
2015-06-19 07:28:47 +12:00
paths.append(appPath(suffix))
2015-06-19 01:03:16 +12:00
# user writable directory
2015-06-19 07:28:47 +12:00
paths.append(writablePath(suffix))
return paths
2016-04-15 23:45:53 +12:00
def tempFile(name):
"Returns a temp file."
return os.path.join(QDir.tempPath(), name)
2016-03-25 01:42:47 +13:00
2015-07-03 23:59:41 +12:00
def totalObjects():
return len(mainWindow().findChildren(QObject))
2016-03-25 01:42:47 +13:00
2015-07-03 23:59:41 +12:00
def printObjects():
2016-03-25 01:42:47 +13:00
print("Objects:", str(totalObjects()))
def findWidgetsOfClass(cls):
"""
Returns all widgets, children of MainWindow, whose class is cls.
@param cls: a class
@return: list of QWidgets
"""
return mainWindow().findChildren(cls, QRegExp())
def findBackground(filename):
"""
Returns the full path to a background file of name filename within resources folders.
"""
return findFirstFile(re.escape(filename), os.path.join("resources", "backgrounds"))
def findFirstFile(regex, path="resources"):
"""
Returns full path of first file matching regular expression regex within folder path,
otherwise returns full path of last file in folder path.
"""
paths = allPaths(path)
for p in paths:
lst = os.listdir(p)
for l in lst:
if re.match(regex, l):
2016-03-30 06:24:35 +13:00
return os.path.join(p, l)
2017-10-24 00:45:08 +13:00
def customIcons():
"""
Returns a list of possible customIcons. String from theme.
"""
r = [
"text-plain",
"gnome-settings",
"applications-internet",
"applications-debugging",
"applications-development",
"system-help",
"info",
"dialog-question",
"dialog-warning",
"stock_timezone",
"stock_people",
"stock_shuffle",
"gtk-index",
"folder_color_picker",
"applications-versioncontrol",
"stock_home",
"stock_trash_empty",
"stock_trash_full",
"stock_yes",
"stock_no",
"stock_notes",
"stock_calendar",
"stock_mic",
'stock_score-lowest', 'stock_score-lower', 'stock_score-low', 'stock_score-normal', 'stock_score-high', 'stock_score-higher', 'stock_score-highest',
"stock_task",
"stock_refresh",
"application-community",
"applications-chat",
"application-menu",
"applications-education",
"applications-science",
2017-10-24 01:46:31 +13:00
"applications-puzzles",
2017-10-24 00:45:08 +13:00
"applications-roleplaying",
"applications-sports",
"applications-libraries",
"applications-publishing",
"applications-development",
"applications-games",
"applications-boardgames",
"applications-geography",
"applications-physics",
"package_multimedia",
"media-flash",
"media-optical",
"media-floppy",
"media-playback-start",
"media-playback-pause",
"media-playback-stop",
"media-playback-record",
"media-playback-start-rtl",
"media-eject",
"document-save",
"gohome",
'purple-folder', 'yellow-folder', 'red-folder', 'custom-folder', 'grey-folder', 'blue-folder', 'default-folder', 'pink-folder', 'orange-folder', 'green-folder', 'brown-folder',
'folder-home', 'folder-remote', 'folder-music', 'folder-saved-search', 'folder-projects', 'folder-sound', 'folder-publicshare', 'folder-pictures', 'folder-saved-search-alt', 'folder-tag',
'calendar-01', 'calendar-02', 'calendar-03', 'calendar-04', 'calendar-05', 'calendar-06', 'calendar-07', 'calendar-08', 'calendar-09', 'calendar-10',
'arrow-down', 'arrow-left', 'arrow-right', 'arrow-up', 'arrow-down-double', 'arrow-left-double', 'arrow-right-double', 'arrow-up-double',
'emblem-added', 'emblem-checked', 'emblem-downloads', 'emblem-dropbox-syncing', 'emblem-danger', 'emblem-development', 'emblem-dropbox-app', 'emblem-art', 'emblem-camera', 'emblem-dropbox-selsync', 'emblem-insync-des-error', 'emblem-insync-error', 'emblem-generic', 'emblem-favorites', 'emblem-error', 'emblem-dropbox-uptodate', 'emblem-marketing', 'emblem-money', 'emblem-music', 'emblem-noread', 'emblem-people', 'emblem-personal', 'emblem-sound', 'emblem-shared', 'emblem-sales', 'emblem-presentation', 'emblem-plan', 'emblem-system', 'emblem-urgent', 'emblem-videos', 'emblem-web',
'face-angel', 'face-clown', 'face-angry', 'face-cool', 'face-devilish', 'face-sick', 'face-sleeping', 'face-uncertain', 'face-monkey', 'face-ninja', 'face-pirate', 'face-glasses', 'face-in-love', 'face-confused',
'feed-marked-symbolic', 'feed-non-starred', 'feed-starred', 'feed-unmarked-symbolic',
'notification-new-symbolic',
]
return sorted(r)
2017-11-11 05:21:02 +13:00
2017-12-08 22:01:58 +13:00
def statusMessage(message, duration=5000, importance=1):
"""
Shows a message in MainWindow's status bar.
2017-12-08 22:01:58 +13:00
Importance: 0 = low, 1 = normal, 2 = important, 3 = critical.
"""
2017-12-08 22:01:58 +13:00
from manuskript.ui import style as S
MW.statusBar().hide()
MW.statusLabel.setText(message)
if importance == 0:
MW.statusLabel.setStyleSheet("color:{};".format(S.textLighter))
elif importance == 1:
MW.statusLabel.setStyleSheet("color:{};".format(S.textLight))
elif importance == 2:
MW.statusLabel.setStyleSheet("color:{}; font-weight: bold;".format(S.text))
elif importance == 3:
MW.statusLabel.setStyleSheet("color:red; font-weight: bold;")
MW.statusLabel.adjustSize()
g = MW.statusLabel.geometry()
# g.moveCenter(MW.mapFromGlobal(MW.geometry().center()))
s = int(MW.layout().spacing() / 2)
2017-12-08 22:01:58 +13:00
g.setLeft(s)
g.moveBottom(MW.mapFromGlobal(MW.geometry().bottomLeft()).y() - s)
MW.statusLabel.setGeometry(g)
MW.statusLabel.show()
QTimer.singleShot(duration, MW.statusLabel.hide)
def openURL(url):
"""
Opens url (string) in browser using desktop default application.
"""
QDesktopServices.openUrl(QUrl(url))
def getSaveFileNameWithSuffix(parent, caption, directory, filter, options=None, selectedFilter=None, defaultSuffix=None):
"""
A reimplemented version of QFileDialog.getSaveFileName() because we would like to make use
of the QFileDialog.defaultSuffix property that getSaveFileName() does not let us adjust.
Note: knowing the selected filter is not an invitation to change the chosen filename later.
"""
dialog = QFileDialog(parent=parent, caption=caption, directory=directory, filter=filter)
if options:
dialog.setOptions(options)
if defaultSuffix:
dialog.setDefaultSuffix(defaultSuffix)
dialog.setFileMode(QFileDialog.AnyFile)
if hasattr(dialog, 'setSupportedSchemes'): # Pre-Qt5.6 lacks this.
dialog.setSupportedSchemes(("file",))
dialog.setAcceptMode(QFileDialog.AcceptSave)
if selectedFilter:
dialog.selectNameFilter(selectedFilter)
if (dialog.exec() == QFileDialog.Accepted):
return dialog.selectedFiles()[0], dialog.selectedNameFilter()
return None, None
2017-11-18 05:38:06 +13:00
def inspect():
"""
Debugging tool. Call it to see a stack of calls up to that point.
"""
import inspect, os
print("-----------------------")
for s in inspect.stack()[1:]:
print(" * {}:{} // {}".format(
os.path.basename(s.filename),
s.lineno,
s.function))
print(" " + "".join(s.code_context))
2019-12-22 04:42:49 +13:00
def search(searchRegex, text):
"""
Search all occurrences of a regex in a text.
:param searchRegex: a regex object with the search to perform
:param text: text to search on
:return: list of tuples (startPos, endPos)
"""
if text is not None:
return [(m.start(), m.end(), getSearchResultContext(text, m.start(), m.end())) for m in searchRegex.finditer(str(text))]
2019-12-22 04:42:49 +13:00
else:
return []
def getSearchResultContext(text, startPos, endPos):
matchSize = endPos - startPos
maxContextSize = max(matchSize, 600)
extraContextSize = int((maxContextSize - matchSize) / 2)
separator = "[...]"
context = ""
i = startPos - 1
while i > 0 and (startPos - i) < extraContextSize and text[i] != '\n':
i -= 1
contextStartPos = i
if i > 0:
context += separator + " "
context += text[contextStartPos:startPos].replace('\n', '')
context += '<b>' + text[startPos:endPos].replace('\n', '') + '</b>'
i = endPos
while i < len(text) and (i - endPos) < extraContextSize and text[i] != '\n':
i += 1
contextEndPos = i
context += text[endPos:contextEndPos].replace('\n', '')
if i < len(text):
context += " " + separator
return context
# Based on answer by jfs at:
# https://stackoverflow.com/questions/3718657/how-to-properly-determine-current-script-directory
def getManuskriptPath(follow_symlinks=True):
"""Used to obtain the path Manuskript is located at."""
if getattr(sys, 'frozen', False): # py2exe, PyInstaller, cx_Freeze
path = os.path.abspath(sys.executable)
else:
import inspect
path = os.path.join(inspect.getabsfile(getManuskriptPath), "..", "..")
if follow_symlinks:
path = os.path.realpath(path)
return os.path.dirname(path)
# Based on answer by kagronik at:
# https://stackoverflow.com/questions/14989858/get-the-current-git-hash-in-a-python-script
def getGitRevision(base_path):
"""Get git revision without relying on external processes or libraries."""
git_dir = pathlib.Path(base_path) / '.git'
if not git_dir.exists():
return None
with (git_dir / 'HEAD').open('r') as head:
ref = head.readline().split(' ')[-1].strip()
with (git_dir / ref).open('r') as git_hash:
return git_hash.readline().strip()
def getGitRevisionAsString(base_path, short=False):
"""Catches errors and presents a nice string."""
try:
rev = getGitRevision(base_path)
if rev is not None:
if short:
rev = rev[:7]
return "#" + rev
else:
return "" # not a git repository
except Exception as e:
LOGGER.warning("Failed to obtain Git revision: %s", e)
return "#ERROR"
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>
2021-04-13 23:32:46 +12:00
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