Finish implementing Markdown editor.

Dockbar Two now has 'Notes' tab that allows investigators to take canvas-specific notes.
Allows Markdown formatting.

Markdown editor now used for editing & displaying notes for entities and links as well.

Breaking change: Canvases file renamed from canvasTabs.lscanvas to CanvasTabs.lscanvas.
This commit is contained in:
AccentuSoft
2022-07-24 11:56:34 +03:00
parent 3d835aed78
commit 90294abe3b
3 changed files with 151 additions and 74 deletions

View File

@@ -18,7 +18,9 @@ from PySide6 import QtWidgets, QtGui, QtCore
from PySide6.QtWidgets import QGraphicsPixmapItem
from PySide6.QtSvgWidgets import QGraphicsSvgItem
from PySide6.QtWebEngineWidgets import QWebEngineView
from Core.Interface import Entity, Stylesheets
from Core.ResourceHandler import RichNotesEditor
class WorkspaceWidget(QtWidgets.QWidget):
@@ -188,6 +190,13 @@ class TabbedPane(QtWidgets.QTabWidget):
self.syncedTabs = []
self.nodeCreationThreads = []
self.canvasDBPath = Path(self.mainWindow.SETTINGS.value("Project/BaseDir")) / "Project Files" / "CanvasTabs.lscanvas"
self.tabsNotesPath = Path(self.mainWindow.SETTINGS.value("Project/FilesDir")).joinpath("CanvasNotes.lsnotes")
self.tabsNotesDict = {}
self.previousTab = None
self.currentChanged.connect(self.currentTabChangedListener)
def addCanvas(self, canvasName='New Graph', graph=None, positions=None, a=0, b=0, c=0, d=0) -> bool:
if not self.isCanvasNameAvailable(canvasName):
return False
@@ -257,11 +266,20 @@ class TabbedPane(QtWidgets.QTabWidget):
return self.canvasTabs[self.tabText(index)]
def closeTab(self, tabName: str) -> None:
"""
Delete tab with name tabName.
@param tabName:
@return:
"""
for tabIndex in range(self.count()):
if self.tabText(tabIndex) == tabName:
self.removeTab(tabIndex)
break
self.canvasTabs.pop(tabName)
try:
self.tabsNotesDict.pop(tabName)
except KeyError:
pass
def hideTab(self, index) -> None:
self.removeTab(index)
@@ -569,25 +587,50 @@ class TabbedPane(QtWidgets.QTabWidget):
def save(self) -> None:
if len(self.canvasTabs) == 0:
return
canvasDBPath = Path(self.mainWindow.SETTINGS.value("Project/BaseDir")) / "Project Files" / "canvasTabs.lscanvas"
canvasDBPathTmp = canvasDBPath.with_suffix(canvasDBPath.suffix + '.tmp')
canvasDBFile = open(canvasDBPathTmp, "wb")
saveJson = {}
for canvasName in self.canvasTabs:
saveJson[canvasName] = [
self.resourceHandler.deconstructGraphForFileDump(self.canvasTabs[canvasName].scene().sceneGraph),
self.canvasTabs[canvasName].scene().scenePos]
dump(saveJson, canvasDBFile)
canvasDBFile.close()
move(canvasDBPathTmp, canvasDBPath)
canvasDBPathTmp = self.canvasDBPath.with_suffix(self.canvasDBPath.suffix + '.tmp')
canvasNotesPathTmp = self.tabsNotesPath.with_suffix(self.tabsNotesPath.suffix + '.tmp')
# Save canvases
with open(canvasDBPathTmp, "wb") as canvasDBFile:
saveJson = {}
for canvasName in self.canvasTabs:
saveJson[canvasName] = [
self.resourceHandler.deconstructGraphForFileDump(self.canvasTabs[canvasName].scene().sceneGraph),
self.canvasTabs[canvasName].scene().scenePos]
dump(saveJson, canvasDBFile)
move(canvasDBPathTmp, self.canvasDBPath)
# Save canvas notes
currIndex = self.currentIndex()
if currIndex != -1:
canvasTabName = self.tabText(currIndex)
if canvasTabName in self.canvasTabs:
self.tabsNotesDict[canvasTabName] = self.mainWindow.dockbarTwo.getNotesText()
with open(canvasNotesPathTmp, "wb") as canvasNotesFileTmp:
dump(self.tabsNotesDict, canvasNotesFileTmp)
move(canvasNotesPathTmp, self.tabsNotesPath)
def open(self) -> None:
canvasDBPath = Path(self.mainWindow.SETTINGS.value("Project/BaseDir")) / "Project Files" / "canvasTabs.lscanvas"
if Path(canvasDBPath).exists():
try:
with open(self.tabsNotesPath, 'rb') as notesFile:
# Load the content into the Notes pane.
self.tabsNotesDict = load(notesFile)
except ValueError:
# If the Tab Notes file is empty or contains invalid input, ignore it.
pass
except FileNotFoundError:
# Create new placeholder notes file if it doesn't exist.
try:
with open(canvasDBPath, "rb") as canvasDBFile:
self.tabsNotesPath.touch(0o700, exist_ok=False)
self.mainWindow.MESSAGEHANDLER.info('Created new Tab Notes file.')
except FileExistsError:
self.mainWindow.MESSAGEHANDLER.error('Race condition occurred while trying to create '
'Tab Notes file.')
if Path(self.canvasDBPath).exists():
try:
with open(self.canvasDBPath, "rb") as canvasDBFile:
savedJson = load(canvasDBFile)
for canvasName in savedJson:
self.addCanvas(canvasName,
@@ -597,6 +640,20 @@ class TabbedPane(QtWidgets.QTabWidget):
self.messageHandler.error("Exception occurred when opening tabs: " + str(exc) +
"\nSkipping opening tabs.", popUp=True)
def currentTabChangedListener(self, newIndex: int) -> None:
if self.previousTab in self.canvasTabs:
# Will never have 'None' canvas, or consider deleted tabs.
self.tabsNotesDict[self.previousTab] = self.mainWindow.dockbarTwo.getNotesText()
if newIndex != -1:
canvasName = self.tabText(newIndex)
tabNotes = self.tabsNotesDict.get(canvasName, '#### Type notes here.\n')
self.mainWindow.dockbarTwo.setNotesText(tabNotes)
self.previousTab = canvasName
else:
self.mainWindow.dockbarTwo.setNotesText("")
self.previousTab = None
class DocWorldPane(QtWidgets.QWidget):
@@ -1948,7 +2005,7 @@ class PropertiesEditor(QtWidgets.QDialog):
continue
keyField = QtWidgets.QLabel(key)
if key == "Notes":
valueField = QtWidgets.QPlainTextEdit(objectJson[key])
valueField = RichNotesEditor(self, objectJson["Notes"])
elif key == 'Icon':
valueField = PropertiesEditorIconField(objectJson[key], objectJson['uid'], self.canvas)
elif key == 'File Path':
@@ -1974,7 +2031,7 @@ class PropertiesEditor(QtWidgets.QDialog):
row, self.itemProperties.LabelRole).widget().text()
if key == "Notes":
value = self.itemProperties.itemAt(
row, self.itemProperties.FieldRole).widget().toPlainText()
row, self.itemProperties.FieldRole).widget().toMarkdown()
elif key == 'Icon':
value = self.itemProperties.itemAt(
row, self.itemProperties.FieldRole).widget().pictureByteArray

View File

@@ -4,9 +4,7 @@ from pathlib import Path
import magic
from PySide6 import QtWidgets, QtCore, QtGui
from Core.Interface import Stylesheets
from Core.ResourceHandler import MinSizeStackedLayout
from typing import Union
from Core.ResourceHandler import MinSizeStackedLayout, RichNotesEditor
class DockBarTwo(QtWidgets.QDockWidget):
@@ -51,6 +49,22 @@ class DockBarTwo(QtWidgets.QDockWidget):
self.initialiseLayout()
def setNotesText(self, newText: str) -> None:
if self.tabNotes.textEditor.isReadOnly():
self.tabNotes.textEditor.contents = newText
self.tabNotes.textEditor.setMarkdown(newText)
else:
self.tabNotes.textEditor.setReadOnly(True)
self.tabNotes.textEditor.contents = newText
self.tabNotes.textEditor.setMarkdown(newText)
def getNotesText(self) -> str:
"""
Get text with the Markdown control characters.
@return:
"""
return self.tabNotes.textEditor.toMarkdown()
class TabNotesPanel(QtWidgets.QWidget):
"""
@@ -62,62 +76,12 @@ class TabNotesPanel(QtWidgets.QWidget):
super(TabNotesPanel, self).__init__(parent=parent)
tabNotesLayout = QtWidgets.QVBoxLayout()
self.textEditor = TabNotesEditor(self)
self.textEditor = RichNotesEditor(self)
tabNotesLayout.addWidget(self.textEditor)
self.setLayout(tabNotesLayout)
class TabNotesEditor(QtWidgets.QTextBrowser):
def __init__(self, parent):
super(TabNotesEditor, self).__init__(parent=parent)
self.setReadOnly(True)
self.setUndoRedoEnabled(True)
self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction |
QtCore.Qt.TextSelectableByKeyboard)
self.contents = '#### Type notes here.\n'
self.setMarkdown(self.contents)
self.textFormat = self.currentCharFormat()
def startEditing(self) -> None:
if self.isReadOnly():
# Reset char format to plain text.
self.setCurrentCharFormat(self.textFormat)
self.setPlainText(self.contents)
self.setReadOnly(False)
def stopEditing(self) -> None:
if not self.isReadOnly():
self.contents = self.toPlainText()
self.setMarkdown(self.contents)
self.setReadOnly(True)
def dropEvent(self, e: QtGui.QDropEvent) -> None:
editingBefore = self.isReadOnly()
self.startEditing()
super(TabNotesEditor, self).dropEvent(e)
if editingBefore:
self.stopEditing()
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
potentialLink = self.anchorAt(ev.pos())
if not potentialLink:
if ev.button() == QtGui.Qt.LeftButton:
self.startEditing()
super(TabNotesEditor, self).mousePressEvent(ev)
def focusOutEvent(self, ev: QtGui.QFocusEvent) -> None:
if not self.underMouse():
if self.isActiveWindow():
self.stopEditing()
super(TabNotesEditor, self).focusOutEvent(ev)
def doSetSource(self, name: Union[QtCore.QUrl, str], resourceType: QtGui.QTextDocument.ResourceType = ...) -> None:
QtGui.QDesktopServices.openUrl(name)
class EntityDetails(QtWidgets.QWidget):
"""
This class is used to display a detailed view of the attributes of
@@ -318,8 +282,7 @@ class EntityDetails(QtWidgets.QWidget):
if key == "uid" or key == "Child UIDs" or key == "Icon":
continue
elif key == "Notes":
notesTextArea = QtWidgets.QPlainTextEdit(jsonDict[key])
notesTextArea.setReadOnly(True)
notesTextArea = RichNotesEditor(self, jsonDict[key], False)
self.detailsLayoutOneNode.addWidget(QtWidgets.QLabel(key), rowCount, 0)
self.detailsLayoutOneNode.addWidget(notesTextArea, rowCount, 1, 10, 1)
rowCount += 9

View File

@@ -14,7 +14,7 @@ from ast import literal_eval
from base64 import b64decode
from dateutil import parser
from PySide6.QtCore import QByteArray, QSize
from PySide6.QtCore import QByteArray, QSize, QUrl, Qt
from PySide6 import QtWidgets, QtGui
from Core.Interface import Stylesheets
@@ -529,3 +529,60 @@ class MinSizeStackedLayout(QtWidgets.QStackedLayout):
def minimumSize(self) -> QSize:
return self.currentWidget().minimumSize()
class RichNotesEditor(QtWidgets.QTextBrowser):
def __init__(self, parent=None, currentText: str = '#### Type notes here.\n', allowEditing: bool = True):
super(RichNotesEditor, self).__init__(parent=parent)
self.allowEditing = allowEditing
self.setReadOnly(True)
if allowEditing:
self.setUndoRedoEnabled(True)
self.setTextInteractionFlags(Qt.TextBrowserInteraction | Qt.TextSelectableByKeyboard)
else:
self.setUndoRedoEnabled(False)
self.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextSelectableByKeyboard
| Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard)
self.contents = currentText
self.setMarkdown(self.contents)
self.textFormat = self.currentCharFormat()
def startEditing(self) -> None:
if self.allowEditing:
if self.isReadOnly():
# Reset char format to plain text.
self.setCurrentCharFormat(self.textFormat)
self.setPlainText(self.contents)
self.setReadOnly(False)
def stopEditing(self) -> None:
if not self.isReadOnly():
self.contents = self.toPlainText()
self.setMarkdown(self.contents)
self.setReadOnly(True)
def dropEvent(self, e: QtGui.QDropEvent) -> None:
if self.allowEditing:
editingBefore = self.isReadOnly()
self.startEditing()
super(RichNotesEditor, self).dropEvent(e)
if editingBefore:
self.stopEditing()
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
potentialLink = self.anchorAt(ev.pos())
if not potentialLink:
if ev.button() == QtGui.Qt.LeftButton:
self.startEditing()
super(RichNotesEditor, self).mousePressEvent(ev)
def focusOutEvent(self, ev: QtGui.QFocusEvent) -> None:
if not self.underMouse():
if self.isActiveWindow():
self.stopEditing()
super(RichNotesEditor, self).focusOutEvent(ev)
def doSetSource(self, name: Union[QUrl, str], resourceType: QtGui.QTextDocument.ResourceType = ...) -> None:
QtGui.QDesktopServices.openUrl(name)