Files
LinkScope_Client/LinkScope.py
AccentuSoft 9c5dd03473 Fix bugs & various optimizations:
- Adjust for installations where venv was not configured.
- Change update strategy to ensure update goes through on all platforms after compilation.
- Make sure that paths work on both Windows & Linux.
- Adjust update strategy to make it work on both Windows & Linux.
2023-10-24 19:04:53 +03:00

3257 lines
155 KiB
Python

#!/usr/bin/env python3
# Load modules
import contextlib
import platform
import re
import sys
import time
import itertools
import threading
import networkx as nx
import qdarktheme
from ast import literal_eval
from uuid import uuid4
from shutil import move
from inspect import getsourcefile
from os import access, R_OK, W_OK, getpid, kill
from os.path import abspath, dirname
from msgpack import load
from pathlib import Path
from datetime import datetime
from typing import Union
from PySide6 import QtWidgets, QtGui, QtCore
from Core import MessageHandler, SettingsObject
from Core import ResourceHandler
from Core import ModuleManager
from Core import EntityDB
from Core import ResolutionManager
from Core import URLManager
from Core import FrontendCommunicationsHandler
from Core.UpdateManager import UpdateManager, UpdaterWindow
from Core.ResourceHandler import resizePictureFromBuffer
from Core.Interface import CentralPane
from Core.Interface import DockBarOne, DockBarTwo, DockBarThree
from Core.Interface import ToolBarOne
from Core.Interface import MenuBar
from Core.Interface.Entity import BaseNode, BaseConnector, GroupNode
from Core.LQL import LQLQueryBuilder, QueryBuilderWizard
from Core.ReportGeneration import ReportWizard
from Core.PathHelper import is_path_exists_or_creatable_portable
# Main Window of Application
class MainWindow(QtWidgets.QMainWindow):
facilitateResolutionSignalListener = QtCore.Signal(str, list)
notifyUserSignalListener = QtCore.Signal(str, str, bool)
statusBarSignalListener = QtCore.Signal(str)
warningSignalListener = QtCore.Signal(str, bool)
errorSignalListener = QtCore.Signal(str, bool, bool)
runningMacroResolutionFinishedSignalListener = QtCore.Signal(str, str, list)
# Redefining the function to adjust its signature.
def centralWidget(self) -> Union[QtWidgets.QWidget, QtWidgets.QWidget, CentralPane.WorkspaceWidget]:
return super(MainWindow, self).centralWidget()
def getSettings(self) -> SettingsObject.SettingsObject:
return self.SETTINGS
# Duplicate and alter a group entity to make it
def copyGroupEntity(self, existingGroupUID: str, targetCanvas: CentralPane.CanvasScene) -> Union[dict, None]:
# Have to make a new group entity, so that ungrouping in one canvas doesn't delete the entity
# group in another.
entityJSON = self.LENTDB.getEntity(existingGroupUID)
# Dereference the list, so we don't have issues w/ the original Group Node.
newChildren = [childUID for childUID in list(entityJSON['Child UIDs'])
if childUID not in targetCanvas.sceneGraph.nodes]
# Don't create the entity if all the nodes in it already exist on the target canvas.
if not newChildren:
return None
newEntity = self.LENTDB.addEntity(
{'Group Name': entityJSON['Group Name'] + ' Copy',
'Child UIDs': newChildren,
'Entity Type': 'EntityGroup'})
newUID = newEntity['uid']
# Need to re-create the links too
for linkUID in self.LENTDB.getOutgoingLinks(entityJSON['uid']):
newLinkJSON = dict(self.LENTDB.getLink(linkUID))
newLinkJSON['uid'] = (newUID, linkUID[1])
self.LENTDB.addLink(newLinkJSON)
for linkUID in self.LENTDB.getIncomingLinks(entityJSON['uid']):
newLinkJSON = dict(self.LENTDB.getLink(linkUID))
newLinkJSON['uid'] = (linkUID[0], newUID)
self.LENTDB.addLink(newLinkJSON)
return newEntity
# What happens when the software is closed
def closeEvent(self, event) -> None:
self.dockbarThree.logViewerUpdateThread.endLogging = True
self.saveTimer.stop()
# Save the window settings
self.SETTINGS.setGlobalValue("MainWindow/Geometry", self.saveGeometry().data())
self.SETTINGS.setGlobalValue("MainWindow/WindowState", self.saveState().data())
self.SETTINGS.setGlobalValue("Program/Usage/First Time Start", False)
if self.FCOM.isConnected():
self.FCOM.close()
self.SETTINGS.setValue("Project/Server/Project", "")
self.saveProject()
# Wait just a little for the logging thread to close.
# We don't _have_ to do this, but it stops errors from popping up due to threads being rudely interrupted.
while not self.dockbarThree.logViewerUpdateThread.isFinished():
time.sleep(0.01)
super(MainWindow, self).closeEvent(event)
# Terminating ThreadPoolExecutor threads, so that the application quits.
for thread in threading.enumerate():
if 'ThreadPoolExecutor' in thread.name:
# Yes, this is terrible. Whenever ThreadPoolExecutor allows for the creation of actual daemon threads
# that don't cause the program to hang, this will be removed.
kill(getpid(), 9)
def saveHelper(self) -> None:
"""
This is where all the saving is done.
Add all save methods here.
NOTE: This function should only be called inside a try/catch.
@return:
"""
self.LENTDB.save()
self.RESOLUTIONMANAGER.save()
self.MODULEMANAGER.save()
self.SETTINGS.save()
self.centralWidget().tabbedPane.save()
def resetMainWindowTitle(self):
self.setWindowTitle(f"LinkScope {self.SETTINGS.value('Program/Version', 'N/A')}"
f" - {self.SETTINGS.value('Project/Name', 'Untitled')}")
def saveProject(self) -> None:
try:
self.saveHelper()
self.setStatus("Project Saved.", 3000)
self.MESSAGEHANDLER.info('Project Saved')
except Exception as e:
errorMessage = f"Could not Save Project: {repr(e)}"
self.MESSAGEHANDLER.error(errorMessage, exc_info=True)
self.setStatus("Failed Saving Project.", 3000)
self.MESSAGEHANDLER.info("Failed Saving Project " + self.SETTINGS.value("Project/Name", 'Untitled'))
def autoSaveProject(self):
try:
self.saveHelper()
self.setStatus("Project Autosaved.", 3000)
except Exception:
self.setStatus("Failed Autosaving Project " + self.SETTINGS.value("Project/Name", 'Untitled'))
def saveAsProject(self) -> None:
if len(self.resolutions) > 0:
self.MESSAGEHANDLER.warning('Cannot Save As project while resolutions are running. Running resolutions: '
+ str(self.resolutions), popUp=True)
return
# Native file dialogs (at least on Ubuntu) return sandboxed paths in some occasions, which messes with the
# saving of the project, since we need to create a directory to save the project in.
saveAsDialog = QtWidgets.QFileDialog()
saveAsDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
saveAsDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
saveAsDialog.setFileMode(QtWidgets.QFileDialog.FileMode.AnyFile)
saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptMode.AcceptSave)
saveAsDialog.setDirectory(str(Path.home()))
if not saveAsDialog.exec():
self.setStatus('Save As operation cancelled.')
return
fileName = saveAsDialog.selectedFiles()[0]
newProjectPath = Path(fileName)
# There is a limit to how long path names can be. This will not prevent all edge cases with nested files,
# since users can have files with absurdly long names, but it should be a reasonable precaution.
if not is_path_exists_or_creatable_portable(str(newProjectPath)):
self.MESSAGEHANDLER.error(
'Invalid project name or path to save at.', popUp=True, exc_info=False)
return
try:
newProjectPath.mkdir(0o700, parents=False, exist_ok=False)
except FileExistsError:
self.MESSAGEHANDLER.error('Cannot save project to an existing directory. Please choose a unique name.',
popUp=True, exc_info=False)
return
except FileNotFoundError:
self.MESSAGEHANDLER.error('Cannot save project into a non-existing parent directory. '
'Please create the required parent directories and try again.',
popUp=True, exc_info=False)
return
oldName = self.SETTINGS.value("Project/Name")
oldBaseDir = self.SETTINGS.value("Project/BaseDir")
oldFilesDir = self.SETTINGS.value("Project/FilesDir")
self.SETTINGS.setValue("Project/BaseDir", str(newProjectPath))
self.SETTINGS.setValue("Project/FilesDir",
str(Path(self.SETTINGS.value("Project/BaseDir")).joinpath("Project Files")))
self.SETTINGS.setValue("Project/Name", newProjectPath.name)
try:
Path(self.SETTINGS.value("Project/FilesDir")).mkdir(0o700, parents=False, exist_ok=False)
except FileExistsError:
self.MESSAGEHANDLER.error('Cannot save project to an existing directory. Please choose a unique name.',
popUp=True, exc_info=False)
self.SETTINGS.setValue("Project/BaseDir", oldBaseDir)
self.SETTINGS.setValue("Project/FilesDir", oldFilesDir)
self.SETTINGS.setValue("Project/Name", oldName)
return
except FileNotFoundError:
self.MESSAGEHANDLER.error('Cannot save project into a non-existing parent directory. '
'Please create the required parent directories and try again.',
popUp=True, exc_info=False)
self.SETTINGS.setValue("Project/BaseDir", oldBaseDir)
self.SETTINGS.setValue("Project/FilesDir", oldFilesDir)
self.SETTINGS.setValue("Project/Name", oldName)
return
self.resetMainWindowTitle()
self.saveProject()
self.setStatus(f'Project Saved As: {newProjectPath.name}')
# https://networkx.org/documentation/stable/reference/readwrite/graphml.html
def exportCanvasToGraphML(self):
currentCanvasGraph = self.centralWidget().tabbedPane.getCurrentScene().sceneGraph
saveAsDialog = QtWidgets.QFileDialog()
saveAsDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
saveAsDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
saveAsDialog.setNameFilter("GraphML (*.xml)")
saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptMode.AcceptSave)
saveAsDialog.setDirectory(str(Path.home()))
if saveAsDialog.exec():
try:
filePath = saveAsDialog.selectedFiles()[0]
if Path(filePath).suffix != '.xml':
filePath += '.xml'
nx.write_graphml(currentCanvasGraph, filePath)
self.setStatus('Canvas exported successfully.')
except Exception as exc:
self.MESSAGEHANDLER.error(f"Could not export canvas to file: {str(exc)}", popUp=True)
self.setStatus('Canvas export failed.')
def importCanvasFromGraphML(self):
openDialog = QtWidgets.QFileDialog()
openDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
openDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
openDialog.setNameFilter("GraphML (*.xml)")
openDialog.setDirectory(str(Path.home()))
if openDialog.exec():
filePath = openDialog.selectedFiles()[0]
try:
read_graphml = nx.read_graphml(filePath)
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
# Deduplication, just in case.
graphMLNodes = set(read_graphml.nodes())
nodesToReadFirst = [self.LENTDB.getEntity(node) for node in graphMLNodes
if not read_graphml.nodes[node].get('groupID')]
nodesToReadFirst = [entity for entity in nodesToReadFirst if entity is not None and
entity['Entity Type'] == 'EntityGroup']
for entity in nodesToReadFirst:
# Create new group entity, so we don't mess with the contents of the original.
if entity['uid'] not in currentScene.sceneGraph.nodes:
newGroupEntity = self.copyGroupEntity(entity['uid'], currentScene)
if newGroupEntity is not None:
currentScene.addNodeProgrammatic(newGroupEntity['uid'], newGroupEntity['Child UIDs'])
graphMLNodes.remove(entity['uid'])
for node in graphMLNodes:
# Need to check as existing group nodes could have been updated with new nodes after export
# but before the import.
if node not in currentScene.sceneGraph.nodes:
currentScene.addNodeProgrammatic(node)
currentScene.rearrangeGraph()
self.setStatus('Canvas imported successfully.')
except KeyError:
self.MESSAGEHANDLER.error("Aborted canvas import: One or more nodes in the graph "
"do not exist in the database.", popUp=True)
self.setStatus('Canvas import aborted.')
except Exception as exc:
self.MESSAGEHANDLER.error(f"Cannot import canvas: {str(exc)}", popUp=True)
self.setStatus('Canvas import failed.')
def exportDatabaseToGraphML(self):
# Need to create a new database to remove the icons
with self.LENTDB.dbLock:
currentDatabase = self.LENTDB.database.copy()
for node in currentDatabase.nodes:
# Remove icons. Will reset custom icons to default, but saves space.
del currentDatabase.nodes[node]['Icon']
if currentDatabase.nodes[node].get('Child UIDs'):
currentDatabase.nodes[node]['Child UIDs'] = str(currentDatabase.nodes[node]['Child UIDs'])
for edge in currentDatabase.edges:
currentDatabase.edges[edge]['uid'] = str(currentDatabase.edges[edge]['uid'])
saveAsDialog = QtWidgets.QFileDialog()
saveAsDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
saveAsDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
saveAsDialog.setNameFilter("GraphML (*.xml)")
saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptMode.AcceptSave)
saveAsDialog.setDirectory(str(Path.home()))
if saveAsDialog.exec():
try:
filePath = saveAsDialog.selectedFiles()[0]
if Path(filePath).suffix != '.xml':
filePath += '.xml'
nx.write_graphml(currentDatabase, filePath)
self.setStatus('Database exported successfully.')
except Exception as exc:
self.MESSAGEHANDLER.error(f"Could not export database to file: {str(exc)}", popUp=True)
self.setStatus('Database export failed.')
def importDatabaseFromGraphML(self):
openDialog = QtWidgets.QFileDialog()
openDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
openDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
openDialog.setNameFilter("GraphML (*.xml)")
openDialog.setDirectory(str(Path.home()))
if openDialog.exec():
try:
filePath = openDialog.selectedFiles()[0]
read_graphml = nx.read_graphml(filePath)
read_graphml_nodes = {key: read_graphml.nodes[key] for key in read_graphml.nodes}
read_graphml_edges = {key: read_graphml.edges[key] for key in read_graphml.edges}
for nodeValue in read_graphml_nodes.values():
# Make sure that all the necessary values are assigned.
# This will throw an exception if the user imports an entity without a type.
nodeValue = self.RESOURCEHANDLER.getEntityJson(nodeValue['Entity Type'], nodeValue)
if nodeValue.get('Child UIDs'):
nodeValue['Child UIDs'] = literal_eval(nodeValue['Child UIDs'])
for edge in read_graphml_edges:
read_graphml_edges[edge]['uid'] = literal_eval(read_graphml_edges[edge]['uid'])
# Make sure that all the necessary values are assigned.
read_graphml_edges[edge] = self.RESOURCEHANDLER.getLinkJson(read_graphml_edges[edge])
self.LENTDB.mergeDatabases(read_graphml_nodes, read_graphml_edges, fromServer=False)
self.dockbarOne.existingEntitiesPalette.loadEntities()
self.LENTDB.resetTimeline()
self.setStatus('Database imported successfully.')
except Exception as exc:
self.MESSAGEHANDLER.error(f"Could not import database from file: {str(exc)}", popUp=True)
self.setStatus('Database import failed.')
def generateReport(self):
wizard = ReportWizard(self)
wizard.show()
def openDirectoryInNativeFileBrowser(self, target_dir) -> None:
QtGui.QDesktopServices.openUrl(QtCore.QUrl.fromLocalFile(target_dir))
def renameProjectPromptName(self) -> None:
newName, confirm = QtWidgets.QInputDialog.getText(self,
'Rename Project',
'New Name:',
QtWidgets.QLineEdit.EchoMode.Normal,
text=self.SETTINGS.value("Project/Name"))
if confirm:
if newName == self.SETTINGS.value("Project/Name"):
self.MESSAGEHANDLER.warning(
"New name must be different than current name.", popUp=True)
return
if newName == '':
self.MESSAGEHANDLER.warning("New name cannot be blank.", popUp=True)
return
self.renameProject(newName)
def renameProject(self, newName: str) -> None:
if len(self.resolutions) > 0:
self.MESSAGEHANDLER.warning('Cannot Rename project while resolutions are running. Running resolutions: '
+ str(self.resolutions), popUp=True)
return
oldName = self.SETTINGS.value("Project/Name")
oldBaseDir = self.SETTINGS.value("Project/BaseDir")
newBaseDir = Path(oldBaseDir).parent.joinpath(newName)
if newBaseDir.exists():
self.MESSAGEHANDLER.error('Path already exists.', popUp=True, exc_info=False)
return
if not is_path_exists_or_creatable_portable(str(newBaseDir)):
self.MESSAGEHANDLER.error(
'Invalid project name or path to save at.', popUp=True, exc_info=False)
return
self.SETTINGS.setValue("Project/BaseDir", str(newBaseDir))
self.SETTINGS.setValue("Project/FilesDir",
str(Path(self.SETTINGS.value("Project/BaseDir")).joinpath("Project Files")))
self.SETTINGS.setValue("Project/Name", newName)
move(oldBaseDir, self.SETTINGS.value("Project/BaseDir"))
oldProjectFile = newBaseDir.joinpath(f'{oldName}.linkscope')
oldProjectFile.unlink(missing_ok=True)
self.resetMainWindowTitle()
self.saveProject()
statusMessage = f'Project Renamed to: {newName}'
self.setStatus(statusMessage)
self.MESSAGEHANDLER.info(statusMessage)
def addCanvas(self) -> None:
# Create or open canvas
connected = self.FCOM.isConnected()
with self.syncedCanvasesLock:
availableSyncedCanvases = self.syncedCanvases
newCanvasPopup = CreateOrOpenCanvas(self, connected, availableSyncedCanvases)
if newCanvasPopup.exec():
self.MESSAGEHANDLER.info(f"New Canvas added: {newCanvasPopup.canvasName}")
def toggleWorldDoc(self) -> None:
if self.centralWidget() is not None:
self.centralWidget().toggleLayout()
def toggleLinkingMode(self) -> None:
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
if currentScene.appendingToGroup:
message = 'Cannot create manual link at this moment: Currently adding entities to group.'
self.MESSAGEHANDLER.info(message, popUp=True)
self.setStatus(message)
return
if self.linkingNodes:
self.centralWidget().tabbedPane.enableAllTabs()
currentScene.linking = False
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.ArrowCursor))
self.linkingNodes = False
else:
self.centralWidget().tabbedPane.disableAllTabsExceptCurrent()
currentScene.linking = True
currentScene.itemsToLink = []
currentScene.clearSelection()
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.CrossCursor))
self.linkingNodes = True
def deleteSpecificEntity(self, itemUID: str) -> None:
self.centralWidget().tabbedPane.nodeRemoveAllHelper(itemUID)
self.LENTDB.removeEntity(itemUID)
self.MESSAGEHANDLER.info(f"Deleted node: {itemUID}")
def deleteSpecificLink(self, linkUIDs: set) -> None:
"""
Remove a set of connections between two nodes. This takes as an argument the set of link UIDs to remove
(i.e. {(uid, uid), ...}) and it removes them from all canvases.
:param linkUIDs: A list of link uid tuples to delete.
:return:
"""
linkUIDs = list(linkUIDs)
for canvas in self.centralWidget().tabbedPane.canvasTabs:
scene = self.centralWidget().tabbedPane.canvasTabs[canvas].scene()
for linkUID in linkUIDs:
scene.removeUIDFromLink(linkUID)
for linkUID in linkUIDs:
self.LENTDB.removeLink(linkUID)
self.MESSAGEHANDLER.info(f"Deleted link: {str(linkUID)}")
def setGroupAppendMode(self, enable: bool) -> None:
if self.centralWidget().tabbedPane.getCurrentScene().linking:
message = 'Cannot append to group: Currently creating new link.'
self.setStatus(message)
self.MESSAGEHANDLER.info(message, popUp=True)
return
if enable:
self.centralWidget().tabbedPane.disableAllTabsExceptCurrent()
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.PointingHandCursor))
else:
self.centralWidget().tabbedPane.enableAllTabs()
self.setCursor(QtGui.QCursor(QtCore.Qt.CursorShape.ArrowCursor))
def selectLeafNodes(self) -> None:
"""
Select the nodes with at least one link coming into them, and no outgoing links.
(i.e. They are a child node in at least 1 relationship, but are not a parent node in any relationship.)
:return:
"""
self.centralWidget().tabbedPane.getCurrentScene().clearSelection()
currentCanvasGraph = self.centralWidget().tabbedPane.getCurrentScene().sceneGraph
leaves = [x for x in currentCanvasGraph.nodes()
if currentCanvasGraph.out_degree(x) == 0 and currentCanvasGraph.in_degree(x) >= 1]
for item in [node for node in self.centralWidget().tabbedPane.getCurrentScene().items()
if isinstance(node, BaseNode)]:
if item.uid in leaves:
item.setSelected(True)
def selectRootNodes(self) -> None:
"""
Select all the nodes that have at least one link going out of them, and no incoming links.
(i.e. They are a parent node in at least 1 relationship, but are not a child node in any relationship.)
:return:
"""
self.centralWidget().tabbedPane.getCurrentScene().clearSelection()
currentCanvasGraph = self.centralWidget().tabbedPane.getCurrentScene().sceneGraph
roots = [x for x in currentCanvasGraph.nodes()
if currentCanvasGraph.out_degree(x) >= 1 and currentCanvasGraph.in_degree(x) == 0]
for item in [node for node in self.centralWidget().tabbedPane.getCurrentScene().items()
if isinstance(node, BaseNode)]:
if item.uid in roots:
item.setSelected(True)
def selectIsolatedNodes(self) -> None:
"""
Select all the nodes that have no links coming into or going out of them.
:return:
"""
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
currentScene.clearSelection()
for isolatedNodeUID in nx.isolates(currentScene.sceneGraph):
currentScene.nodesDict[isolatedNodeUID].setSelected(True)
def selectNonIsolatedNodes(self) -> None:
"""
Select all nodes with at least one link going into or out of them.
:return:
"""
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
currentScene.clearSelection()
isolatedNodes = list(nx.isolates(currentScene.sceneGraph))
for node in currentScene.nodesDict:
if node not in isolatedNodes:
currentScene.nodesDict[node].setSelected(True)
def findShortestPath(self) -> None:
"""
Find the shortest path between two nodes, if it exists.
Exactly two nodes must be selected.
:return:
"""
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
endPoints = [item.uid for item in currentScene.selectedItems()
if isinstance(item, BaseNode)]
if len(endPoints) != 2:
self.MESSAGEHANDLER.warning('Exactly two entities must be selected for the Shortest Path function to work.',
popUp=True)
return
currentCanvasGraph = currentScene.sceneGraph
try:
shortestPath = nx.shortest_path(currentCanvasGraph, endPoints[0], endPoints[1])
except nx.NetworkXNoPath:
try:
shortestPath = nx.shortest_path(currentCanvasGraph, endPoints[1], endPoints[0])
except nx.NetworkXNoPath:
shortestPath = None
if shortestPath is None:
messagePathNotFound = f'No path found connecting the selected nodes: {endPoints}'
self.setStatus(messagePathNotFound)
self.MESSAGEHANDLER.info(messagePathNotFound, popUp=True)
else:
currentScene.clearSelection()
for itemUID in currentScene.nodesDict:
if itemUID in shortestPath:
currentScene.nodesDict[itemUID].setSelected(True)
linksToSelect = list(zip(shortestPath, shortestPath[1:]))
for linkItem in [link for link in currentScene.items() if isinstance(link, BaseConnector)]:
if linkItem.uid.intersection(linksToSelect):
linkItem.setSelected(True)
self.setStatus('Shortest path found.')
def extractCycles(self) -> None:
"""
Find cycles in the graph, optionally involving selected nodes.
:return:
"""
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
canvasName = currentScene.getSelfName()
endPoints = [item.uid for item in currentScene.selectedItems()
if isinstance(item, BaseNode)]
currentCanvasGraph = currentScene.sceneGraph
tempGraph = currentCanvasGraph.copy()
# Just in case.
tempGraph.remove_edges_from(nx.selfloop_edges(currentCanvasGraph))
newCyclesThread = ExtractCyclesThread(tempGraph, endPoints, canvasName)
newCyclesThread.cyclesSignal.connect(self.extractCyclesResultHandler)
self.MESSAGEHANDLER.info(f'Extracting Cycles from Canvas: {canvasName}')
newCyclesThread.start()
self.cycleExtractionThreads.append(newCyclesThread)
def extractCyclesResultHandler(self, results: list, canvasName: str) -> None:
if not results:
self.MESSAGEHANDLER.info(f'No Cycles in Canvas: {canvasName}')
else:
count = 0
while True:
newCanvasName = f'{canvasName} Cycles #{str(count)}'
if self.centralWidget().tabbedPane.addCanvas(newCanvasName):
break
else:
count += 1
nodesToAdd = results[0]
groupsToMake = results[1]
newCanvas = self.centralWidget().tabbedPane.canvasTabs[newCanvasName].scene()
for node in nodesToAdd:
newCanvas.addNodeProgrammatic(node)
entitiesAlreadyInGroups = set()
for count, group in enumerate(groupsToMake, start=1):
newGroup = set()
for groupEntity in group:
if groupEntity not in entitiesAlreadyInGroups:
newGroup.add(groupEntity)
entitiesAlreadyInGroups.add(groupEntity)
newCanvas.groupItemsProgrammatic(newGroup, f'Group {str(count)}')
newCanvas.rearrangeGraph('circular')
for cycleThread in list(self.cycleExtractionThreads):
if cycleThread.isFinished():
cycleThread.deleteLater()
self.cycleExtractionThreads.remove(cycleThread)
def findEntityOrLinkOnCanvas(self, regex: bool = False) -> None:
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
currentUIDs = [item.uid for item in currentScene.items() if isinstance(item, (BaseNode, BaseConnector))]
entityPrimaryFields = {}
for uid in currentUIDs:
if isinstance(uid, str):
item = self.LENTDB.getEntity(uid)
if item is not None:
if not entityPrimaryFields.get(item[list(item)[1]]):
entityPrimaryFields[item[list(item)[1]]] = set()
entityPrimaryFields[item[list(item)[1]]].add(uid)
elif isinstance(uid, set):
for potentialLinkItem in uid:
item = self.LENTDB.getLink(potentialLinkItem)
if item is not None:
if not entityPrimaryFields.get(item['Resolution']):
entityPrimaryFields[item['Resolution']] = set()
entityPrimaryFields[item['Resolution']].add(str(uid))
findPrompt = FindEntityOnCanvasDialog(list(entityPrimaryFields), regex)
if findPrompt.exec():
findText = findPrompt.findInput.text()
if findText != "":
uidsToSelect = []
try:
if regex:
expression = re.compile(findText)
for item in entityPrimaryFields:
if expression.match(item):
# Add the elements in each index to uidsToSelect instead of the sets themselves.
uidsToSelect.extend(entityPrimaryFields[item])
else:
for item in entityPrimaryFields:
if item.startswith(findText):
# Add the elements in each index to uidsToSelect instead of the sets themselves.
uidsToSelect.extend(entityPrimaryFields[item])
currentScene.clearSelection()
for item in [linkOrEntity for linkOrEntity in currentScene.items()
if isinstance(linkOrEntity, (BaseNode, BaseConnector))]:
if str(item.uid) in uidsToSelect:
item.setSelected(True)
if len(uidsToSelect) == 1 and ',' not in uidsToSelect[0]:
self.centralWidget().tabbedPane.getCurrentView().centerViewportOnNode(uidsToSelect[0])
except re.error:
self.MESSAGEHANDLER.error('Invalid Regex Specified!', popUp=True, exc_info=False)
def findEntityOfTypeOnCanvas(self, regex: bool = False) -> None:
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
currentUIDs = [item.uid for item in currentScene.items() if isinstance(item, BaseNode)]
# Keys: Entity Types. Values: dicts, where the key is the primary field and the value is the UID.
entityTypesOnCanvas = {}
for uid in currentUIDs:
item = self.LENTDB.getEntity(uid)
if item is not None:
itemType = item.get('Entity Type')
itemPrimaryFieldValue = item[self.RESOURCEHANDLER.getPrimaryFieldForEntityType(itemType)]
if itemType not in entityTypesOnCanvas:
entityTypesOnCanvas[itemType] = {}
if itemPrimaryFieldValue not in entityTypesOnCanvas[itemType]:
entityTypesOnCanvas[itemType][itemPrimaryFieldValue] = set()
entityTypesOnCanvas[itemType][itemPrimaryFieldValue].add(uid)
findPrompt = FindEntityOfTypeOnCanvasDialog(self, entityTypesOnCanvas, regex)
if findPrompt.exec():
findText = findPrompt.findInput.text()
findType = findPrompt.typeInput.currentText()
if findText != "":
uidsToSelect = []
try:
if regex:
expression = re.compile(findText)
for item in entityTypesOnCanvas[findType]:
if expression.match(item):
# Add the elements in each index to uidsToSelect instead of the sets themselves.
uidsToSelect.extend(entityTypesOnCanvas[findType][item])
else:
for item in entityTypesOnCanvas[findType]:
if item.startswith(findText):
# Add the elements in each index to uidsToSelect instead of the sets themselves.
uidsToSelect.extend(entityTypesOnCanvas[findType][item])
currentScene.clearSelection()
for item in [sceneEntity for sceneEntity in currentScene.items()
if isinstance(sceneEntity, BaseNode)]:
if item.uid in uidsToSelect:
item.setSelected(True)
if len(uidsToSelect) == 1:
self.centralWidget().tabbedPane.getCurrentView().centerViewportOnNode(uidsToSelect[0])
except re.error:
self.MESSAGEHANDLER.error('Invalid Regex Specified!', popUp=True, exc_info=False)
def findResolution(self) -> None:
# Dereference the entities and resolutions, just to be safe.
findDialog = ResolutionManager.FindResolutionDialog(self, list(self.RESOURCEHANDLER.getAllEntities()),
dict(self.RESOLUTIONMANAGER.resolutions))
findDialog.exec()
def mergeEntities(self) -> None:
"""
Show table of entities w/ primary fields, and incoming / outgoing links.
Let user choose which entity should be the primary one. For all the rest:
Get all links to and from them, and add them to the primary one.
Add their fields to the primary one, if they are not the same.
Delete them when done.
:return:
"""
entitiesToMerge = [self.LENTDB.getEntity(item.uid)
for item in self.centralWidget().tabbedPane.getCurrentScene().selectedItems()
if isinstance(item, BaseNode) and not isinstance(item, GroupNode)]
if len(entitiesToMerge) < 2:
self.MESSAGEHANDLER.info('Not enough valid entities to merge selected! Please choose at least two'
' non-Meta entities.', popUp=True)
return
mergeDialog = MergeEntitiesDialog(self, entitiesToMerge)
if mergeDialog.exec():
primaryEntityUID = mergeDialog.primaryEntityUID
primaryEntity = [entity for entity in entitiesToMerge if entity['uid'] == primaryEntityUID][0]
otherEntitiesUIDs = mergeDialog.otherEntitiesUIDs # First entity is written first. No overwrites.
allParentsPrimary = [link[0] for link in self.LENTDB.getIncomingLinks(primaryEntityUID)]
allChildrenPrimary = [link[1] for link in self.LENTDB.getOutgoingLinks(primaryEntityUID)]
# Do not want links pointing to itself.
allParentsPrimary.append(primaryEntityUID)
allChildrenPrimary.append(primaryEntityUID)
linksToAdd = []
for entityUID in otherEntitiesUIDs:
otherEntity = [otherEntity for otherEntity in entitiesToMerge if otherEntity['uid'] == entityUID][0]
# Do not include links to / from entities where such links exist already on the primary entity.
# Also do not include links to / from other entities that are being merged.
allIncomingLinks = [link for link in self.LENTDB.getIncomingLinks(entityUID)
if link[0] not in allParentsPrimary and
link[0] not in otherEntitiesUIDs]
allOutgoingLinks = [link for link in self.LENTDB.getOutgoingLinks(entityUID)
if link[1] not in allChildrenPrimary
and link[1] not in otherEntitiesUIDs]
for field in otherEntity:
# Check if field does not exist, or if it does, but contains 'None' value.
if str(primaryEntity.get(field)) == 'None':
primaryEntity[field] = otherEntity[field]
for incomingLink in allIncomingLinks:
linkJson = self.LENTDB.getLink(incomingLink)
linksToAdd.append([incomingLink[0], primaryEntity['uid'], linkJson['Resolution'],
linkJson['Notes']])
for outgoingLink in allOutgoingLinks:
linkJson = self.LENTDB.getLink(outgoingLink)
linksToAdd.append([primaryEntity['uid'], outgoingLink[1], linkJson['Resolution'],
linkJson['Notes']])
self.deleteSpecificEntity(entityUID)
self.centralWidget().tabbedPane.linkAddHelper(linksToAdd)
def splitEntity(self) -> None:
entityToSplit = [self.LENTDB.getEntity(item.uid)
for item in self.centralWidget().tabbedPane.getCurrentScene().selectedItems()
if isinstance(item, BaseNode)]
validEntityToSplit = [entity for entity in entityToSplit
if entity['Entity Type'] != 'EntityGroup']
if not validEntityToSplit:
self.MESSAGEHANDLER.info('No valid entities to split selected! Please choose at least one non-Meta entity.',
popUp=True)
return
elif len(validEntityToSplit) > 1:
self.MESSAGEHANDLER.info('Multiple entities selected. Please pick one valid entity to split.',
popUp=True)
return
# Continue only if a single entity is selected.
entityToSplit = validEntityToSplit[0]
entityToSplitPrimaryFieldKey = list(entityToSplit)[1]
entityToSplitUID = entityToSplit['uid']
splitDialog = SplitEntitiesDialog(self, entityToSplit)
if splitDialog.exec():
canvasTabs = self.centralWidget().tabbedPane.canvasTabs
allScenesWithNode = [canvasTabs[view].scene() for view in canvasTabs
if entityToSplitUID in canvasTabs[view].scene().sceneGraph.nodes()]
for newEntityWithLinks in splitDialog.splitEntitiesWithLinks:
newEntity = {entityToSplitPrimaryFieldKey: newEntityWithLinks[0]}
for field in entityToSplit:
if field not in ['uid', entityToSplitPrimaryFieldKey]:
newEntity[field] = entityToSplit[field]
newEntity = self.LENTDB.addEntity(newEntity)
for link in newEntityWithLinks[1]:
newLink = {field: link[field] for field in link}
if newLink['uid'][0] == entityToSplitUID:
newLink['uid'] = (newEntity['uid'], newLink['uid'][1])
else:
newLink['uid'] = (newLink['uid'][0], newEntity['uid'])
self.LENTDB.addLink(newLink)
for scene in allScenesWithNode:
scene.addNodeProgrammatic(newEntity['uid'])
self.deleteSpecificEntity(entityToSplitUID)
for scene in allScenesWithNode:
scene.rearrangeGraph()
def launchQueryWizard(self):
queryWizard = QueryBuilderWizard(self)
queryWizard.exec()
def handleGroupNodeUpdateAfterEntityDeletion(self, entityUID) -> None:
for canvas in self.centralWidget().tabbedPane.canvasTabs:
self.centralWidget().tabbedPane.canvasTabs[canvas].cleanDeletedNodeFromGroupsIfExists(entityUID)
def editProjectSettings(self) -> None:
settingsDialog = ProjectEditDialog(self.SETTINGS)
if settingsDialog.exec():
# Save new settings
newSettings = settingsDialog.newSettings
for key in newSettings:
newSettingValue = newSettings[key]
if newSettingValue[1]:
# Delete key
self.SETTINGS.removeKey(key)
elif newSettingValue[0] != '':
# Do not allow blank settings.
if key in ['Project/Resolution Result Grouping Threshold', 'Project/Number of Answers Returned',
'Project/Question Answering Retriever Value', 'Project/Question Answering Reader Value']:
with contextlib.suppress(ValueError):
int(newSettingValue[1])
self.SETTINGS.setValue(key, newSettingValue[0])
elif newSettingValue[0] in ['Copy', 'Symlink']:
self.SETTINGS.setValue(key, newSettingValue[0])
self.saveProject()
def editResolutionsSettings(self) -> None:
settingsDialog = ResolutionsEditDialog(self.SETTINGS)
if settingsDialog.exec():
# Save new settings
newSettings = settingsDialog.newSettings
for key in newSettings:
newSettingValue = newSettings[key]
if newSettingValue[1]:
# Delete key
self.SETTINGS.removeKey(key)
elif newSettingValue[0] != '':
# Do not allow blank settings.
self.SETTINGS.setValue(key, newSettingValue[0])
self.saveProject()
def editLogSettings(self) -> None:
settingsDialog = LoggingSettingsDialog(self.SETTINGS)
if settingsDialog.exec():
# Save new settings
newSettings = settingsDialog.newSettings
for key in newSettings:
newSettingValue = newSettings[key]
if newSettingValue[1]:
# Delete key
self.SETTINGS.removeKey(key)
elif newSettingValue[0] != '':
# Do not allow blank settings.
self.SETTINGS.setValue(key, newSettingValue[0])
self.MESSAGEHANDLER.setSeverityLevel(self.SETTINGS.value('Logging/Severity'))
self.MESSAGEHANDLER.changeLogfile(self.SETTINGS.value('Logging/Logfile'))
self.saveProject()
def editProgramSettings(self) -> None:
settingsDialog = ProgramEditDialog(self)
if settingsDialog.exec():
# Save new settings
newSettings = settingsDialog.newSettings
for key in newSettings:
newSettingValue = newSettings[key]
if newSettingValue[1]:
# Delete key
self.SETTINGS.removeKey(key)
elif newSettingValue[0] != '':
# Do not allow blank settings.
self.SETTINGS.setValue(key, newSettingValue[0])
self.saveProject()
def changeGraphics(self) -> None:
settingsDialog = GraphicsEditDialog(self.SETTINGS, self.RESOURCEHANDLER)
if settingsDialog.exec():
newSettings = settingsDialog.newSettings
with contextlib.suppress(ValueError):
etfVal = int(newSettings["ETF"])
self.centralWidget().tabbedPane.entityTextFont.setPointSize(etfVal)
self.SETTINGS.setGlobalValue("Program/Graphics/Entity Text Font Size", str(newSettings["ETF"]))
with contextlib.suppress(ValueError):
ltfVal = int(newSettings["LTF"])
self.centralWidget().tabbedPane.linkTextFont.setPointSize(ltfVal)
self.SETTINGS.setGlobalValue("Program/Graphics/Link Text Font Size", str(newSettings["LTF"]))
with contextlib.suppress(ValueError):
lfVal = int(newSettings["LF"])
self.centralWidget().tabbedPane.hideZoom = -lfVal
self.centralWidget().tabbedPane.updateCanvasHideZoom()
self.SETTINGS.setGlobalValue("Program/Graphics/Label Fade Scroll Distance", str(newSettings["LF"]))
etcVal = newSettings["ETC"]
newEtcColor = QtGui.QColor(etcVal)
if newEtcColor.isValid():
self.centralWidget().tabbedPane.entityTextBrush.setColor(newEtcColor)
self.SETTINGS.setGlobalValue("Program/Graphics/Entity Text Color", newEtcColor.name())
ltcVal = newSettings["LTC"]
newLtcColor = QtGui.QColor(ltcVal)
if newLtcColor.isValid():
self.centralWidget().tabbedPane.linkTextBrush.setColor(newLtcColor)
self.SETTINGS.setGlobalValue("Program/Graphics/Link Text Color", newLtcColor.name())
self.centralWidget().tabbedPane.updateCanvasGraphics()
self.saveProject()
def loadModules(self) -> None:
"""
Loads user-defined modules from the Modules folder in the installation directory.
:return:
"""
self.MODULEMANAGER.loadAllModules()
self.setStatus('Loaded Modules.')
def reloadModules(self, onlyUpdateDockbar: bool = False) -> None:
"""
Same as loadModules, except this one updates the GUI to show newly loaded entities and resolutions.
This is meant to be run after the application started, in case the user wants to load a module without
closing and reopening the application.
:return:
"""
if not onlyUpdateDockbar:
self.MODULEMANAGER.loadAllModules()
self.dockbarOne.existingEntitiesPalette.loadEntities()
self.dockbarOne.resolutionsPalette.loadAllResolutions()
self.dockbarOne.nodesPalette.loadEntities()
self.setStatus('Reloaded Modules.')
def setStatus(self, message: str, timeout: int = 5000) -> None:
"""
Show the message provided in the status bar.
Timeout dictates how long the message is shown, in milliseconds.
Translates the message automatically, no need to call 'tr' on the message parameter used.
"""
self.statusBar().showMessage(self.tr(message), timeout)
def notifyUser(self, message: str, title: str = "LinkScope Notification", beep: bool = True,
icon: QtGui.QIcon = None) -> None:
if icon is not None:
self.trayIcon.showMessage(self.tr(title), self.tr(message), icon)
else:
self.trayIcon.showMessage(self.tr(title), self.tr(message))
if beep:
application.beep()
def getPictureOfCanvas(self, canvasName: str, justViewport: bool = True,
transparentBackground: bool = False) -> Union[QtGui.QPicture, None]:
view = self.centralWidget().tabbedPane.canvasTabs.get(canvasName)
if view is None:
return None
return view.takePictureOfView(justViewport, transparentBackground)
def resetTimeline(self, graph: nx.DiGraph) -> None:
self.dockbarThree.timeWidget.resetTimeline(graph, True)
def updateTimeline(self, node, added: bool = True, updateGraph: bool = True) -> None:
self.dockbarThree.timeWidget.updateTimeline(node, added, updateGraph)
def timelineSelectMatchingEntities(self, timescale: list) -> None:
if not timescale: # i.e. if timescale == []
return
scene = self.centralWidget().tabbedPane.getCurrentScene()
scene.clearSelection()
for uid in scene.nodesDict:
if self.LENTDB.isNode(uid):
createdDate = datetime.fromisoformat(self.LENTDB.getEntity(uid)['Date Created'])
try:
year = timescale[0]
except IndexError:
year = None
try:
month = timescale[1]
except IndexError:
month = None
try:
day = timescale[2]
except IndexError:
day = None
try:
hour = timescale[3]
except IndexError:
hour = None
try:
minute = timescale[4]
except IndexError:
minute = None
if minute is not None and createdDate.minute != minute:
continue
if hour is not None and createdDate.hour != hour:
continue
if day is not None and createdDate.day != day:
continue
if month is not None and createdDate.month != month:
continue
if year is not None and createdDate.year != year:
continue
scene.nodesDict[uid].setSelected(True)
def setCurrentCanvasSelection(self, uidList: list) -> None:
currScene = self.centralWidget().tabbedPane.getCurrentScene()
currScene.clearSelection()
for item in uidList:
if isinstance(item, str):
nodeItem = currScene.getVisibleNodeForUID(item)
if nodeItem is not None:
nodeItem.setSelected(True)
else:
linkItem = currScene.getVisibleLinkForUID(item)
if linkItem is not None:
linkItem.setSelected(True)
else:
for entityItem in item:
nodeItem = currScene.getVisibleNodeForUID(entityItem)
if nodeItem is not None:
nodeItem.setSelected(True)
if len(uidList) == 1 and isinstance(uidList[0], str):
self.centralWidget().tabbedPane.getCurrentView().centerViewportOnNode(uidList[0])
def populateDetailsWidget(self, uids) -> None:
eJson = []
for uid in uids:
# Connectors give the list if edge UIDs they represent
if isinstance(uid, set):
eJson.extend(self.LENTDB.getLink(linkUID) for linkUID in uid)
else:
eJson.append(self.LENTDB.getEntity(uid))
self.dockbarTwo.entDetails.displayWidgetDetails(eJson)
def updateEntityNodeLabelsOnCanvases(self, uid: str, label: str) -> None:
"""
If an entity is updated (i.e. re-added) to the database, the labels
for each node on each canvas need to be updated as well.
:param label:
:param uid:
:return:
"""
for tab in self.centralWidget().tabbedPane.canvasTabs:
scene = self.centralWidget().tabbedPane.canvasTabs[tab].scene()
with contextlib.suppress(KeyError):
scene.nodesDict[uid].updateLabel(label)
def updateLinkLabelsOnCanvases(self, uid: str, label: str) -> None:
"""
If a link is updated (i.e. re-added) to the database, the labels
for each link label on each canvas need to be updated as well.
:param label:
:param uid:
:return:
"""
for tab in self.centralWidget().tabbedPane.canvasTabs:
scene = self.centralWidget().tabbedPane.canvasTabs[tab].scene()
with contextlib.suppress(KeyError):
scene.linksDict[uid].updateLabel(label)
def getPlaywrightBrowserPath(self, browser: str = None) -> Path:
browserPath = self.MODULEMANAGER.browsersBaseDirectoryPath
if browser in {'firefox', 'chromium'}:
browserDir = next((b_dir for b_dir in browserPath.iterdir()
if b_dir.name.startswith(browser) and b_dir.is_dir()),
None)
if browserDir is not None:
browserPath = [subdirectory for subdirectory in browserDir.iterdir() if subdirectory.is_dir()][0]
if browser == 'firefox':
browserPath /= 'firefox.exe' if platform.system() == 'Windows' else 'firefox'
elif browser == 'chromium':
browserPath /= 'chrome.exe' if platform.system() == 'Windows' else 'chrome'
else:
self.MESSAGEHANDLER.critical('Cannot find installed playwright browsers.', exc_info=False)
return browserPath
def populateEntitiesWidget(self, eJson: dict, add: bool) -> None:
if add:
self.dockbarOne.existingEntitiesPalette.addEntity(eJson)
else:
self.dockbarOne.existingEntitiesPalette.removeEntity(eJson)
def populateResolutionsWidget(self, selected) -> None:
self.dockbarOne.resolutionsPalette.loadResolutionsForSelected(selected)
def popParameterValuesAndReturnSpecified(self, resolutionName: str, parameters: dict,
preSpecifiedParameters: dict = None) -> dict:
"""
This takes a dict of parameters (see Example Resolution for dict format) and a resolution name.
We remove the specified parameters from the given 'parameters' dict, and return a dict with
their respective values.
@param preSpecifiedParameters: Optional dict containing values provided via alternative means (i.e. macros)
@param resolutionName: The name of the resolution whose parameters we are getting
@param parameters: The parameter dict of a resolution
@return:
"""
specifiedParameterValues = {}
# New dict given in argument, so we can pop stuff from it while we loop through it.
for parameter in dict(parameters):
if preSpecifiedParameters is not None and parameter in preSpecifiedParameters:
savedParameterValue = preSpecifiedParameters.get(parameter)
elif parameters[parameter].get('global') is True:
# Extra slash in the middle to ensure that resolutions cannot overwrite these accidentally (or not),
# since slashes are not allowed by default on Linux or Windows.
savedParameterValue = self.SETTINGS.value(f'Resolutions/Global/Parameters/{parameter}')
else:
savedParameterValue = self.SETTINGS.value(f'Resolutions/{resolutionName}/{parameter}')
if savedParameterValue is not None:
specifiedParameterValues[parameter] = savedParameterValue
parameters.pop(parameter)
return specifiedParameterValues
def runResolution(self, resolutionName, preSpecifiedUID: str = None, preSpecifiedEntities: list = None,
preSpecifiedParameters: dict = None) -> str:
"""
Runs the specified resolution in another thread.
"""
try:
category, resolution = resolutionName.split('/', 1)
except ValueError:
self.MESSAGEHANDLER.error(
f'Category name or resolution name should not contain slashes: {resolutionName}'
)
return ""
if preSpecifiedEntities:
resolutionInputEntities = preSpecifiedEntities
else:
scene = self.centralWidget().tabbedPane.getCurrentScene()
items = scene.selectedItems()
resolutionInputEntities = [self.LENTDB.getEntity(item.uid) for item in items if isinstance(item, BaseNode)]
parameters = self.RESOLUTIONMANAGER.getResolutionParameters(category, resolution)
if parameters is None:
message = f'Resolution parameters not found for resolution: {resolution}'
self.MESSAGEHANDLER.error(message, popUp=True, exc_info=False)
self.setStatus(message)
return ""
resolutionParameterValues = self.popParameterValuesAndReturnSpecified(resolution, parameters,
preSpecifiedParameters)
# Show Resolution wizard if there are any parameters required that aren't saved in settings, or
# if the user did not select any items to run the resolution on.
# Note that at this point, the parameters dict contains only the parameters that are unspecified, i.e.
# ones that were not saved previously.
if not resolutionInputEntities or parameters:
selectEntityList = None
uidAndPrimaryFields: list = []
acceptableOriginTypes = None
resolutionDescription = None
if not resolutionInputEntities:
acceptableOriginTypes = self.RESOLUTIONMANAGER.getResolutionOriginTypes(resolutionName)
uidAndPrimaryFields = [(entity['uid'], entity[list(entity)[1]])
for entity in self.LENTDB.getAllEntities()
if entity['Entity Type'] in acceptableOriginTypes]
selectEntityList = [entity[1] for entity in uidAndPrimaryFields]
resolutionDescription = self.RESOLUTIONMANAGER.getResolutionDescription(resolutionName)
parameterSelector = ResolutionManager.ResolutionParametersSelector(
self, resolution, parameters, selectEntityList, acceptableOriginTypes, resolutionDescription)
if parameterSelector.exec():
resolutionParameterValues.update(parameterSelector.chosenParameters)
if not resolutionInputEntities:
selectedEntities = parameterSelector.entitySelector.selectedItems()
if len(selectedEntities) == 0:
self.setStatus(f'Resolution {resolution} did not run: No entities selected.')
return ""
for selectedEntity in selectedEntities:
uid = uidAndPrimaryFields[selectEntityList.index(selectedEntity.text())][0]
resolutionInputEntities.append(self.LENTDB.getEntity(uid))
else:
self.setStatus(f'Resolution {resolution} aborted.')
return ""
resolutionParameterValues['Project Files Directory'] = self.SETTINGS.value("Project/FilesDir")
resolutionParameterValues['Playwright Firefox'] = self.getPlaywrightBrowserPath('firefox')
resolutionParameterValues['Playwright Chromium'] = self.getPlaywrightBrowserPath('chromium')
resolutionUID = preSpecifiedUID or str(uuid4())
resolutionThread = ResolutionManager.ResolutionExecutorThread(
resolutionName, resolutionInputEntities, resolutionParameterValues, self, resolutionUID)
resolutionThread.sig.connect(self.resolutionSignalListener)
resolutionThread.sigStr.connect(self.resolutionSignalListener)
resolutionThread.sigError.connect(self.resolutionErrorSignalListener)
self.MESSAGEHANDLER.info(f'Running Resolution: {resolution}')
self.setStatus(f"Running Resolution: {resolution}")
resolutionThread.start()
self.resolutions.append((resolutionThread, category == 'Server Resolutions'))
return resolutionUID
def resolutionSignalListener(self, resolution_name: str, resolution_result: Union[list, str],
resolution_uid: str) -> None:
"""
Is called by the threads created by runResolution to handle the
result, i.e. run the function that adds nodes and links.
"""
affectedUIDs = []
if isinstance(resolution_result, str):
self.MESSAGEHANDLER.info(f"Resolution {resolution_name} finished with status: {resolution_result}",
popUp=True)
elif len(resolution_result) == 0:
self.MESSAGEHANDLER.info(f"Resolution {resolution_name} returned no results.", popUp=True)
else:
affectedUIDs = self.centralWidget().tabbedPane.facilitateResolution(resolution_name, resolution_result)
self.cleanUpLocalFinishedResolutions()
self.setStatus(f"Resolution: {resolution_name} completed.")
self.runningMacroResolutionFinishedSignalListener.emit(resolution_name, resolution_uid, affectedUIDs)
def resolutionErrorSignalListener(self, error_message: str):
self.MESSAGEHANDLER.error(error_message, popUp=True)
def cleanUpLocalFinishedResolutions(self) -> None:
"""
Clean out old non-server resolutions.
:return:
"""
for resolutionThread in list(self.resolutions):
if resolutionThread[0].isFinished() and resolutionThread[1] is False:
resolutionThread[0].deleteLater()
self.resolutions.remove(resolutionThread)
def resolutionFinishedMacrosListener(self, resolution_name: str, resolution_uid: str,
affectedEntityUIDs: list) -> None:
"""
When user runs macro, the macro goes into a list.
Emissions from resolutionSignalListener are caught here, and the macro keeps track of what
UIDs correspond to each stage of itself, and progresses appropriately.
@param affectedEntityUIDs:
@param resolution_name:
@param resolution_uid:
@return:
"""
with self.macrosLock:
macroValid = False
for runningMacro in self.runningMacros:
currentResolutionForMacro = runningMacro[0]
if resolution_name == currentResolutionForMacro[0] and \
resolution_uid == currentResolutionForMacro[1]:
macroValid = True
break
if macroValid:
runNext = False
runningMacro.pop(0)
if runningMacro:
nextResolutionForMacro = runningMacro[0]
acceptableOriginTypes = self.RESOLUTIONMANAGER.getResolutionOriginTypes(nextResolutionForMacro[0])
fullEntityJsonList = [self.LENTDB.getEntity(entityUID) for entityUID in set(affectedEntityUIDs)]
if filteredEntityJsonList := [
entity
for entity in fullEntityJsonList
if entity['Entity Type'] in acceptableOriginTypes
]:
preparedResolutionArguments = [nextResolutionForMacro[0], nextResolutionForMacro[1],
filteredEntityJsonList, nextResolutionForMacro[2]]
runNext = True
if not runNext:
self.setStatus('Macro execution finished.')
self.runningMacros.remove(runningMacro)
if macroValid and runNext:
self.runResolution(*preparedResolutionArguments)
def showMacrosDialog(self) -> None:
"""
Also runs any macros selected by the user.
@return:
"""
macrosDialog = ResolutionManager.MacroDialog(self)
if macrosDialog.exec():
macrosToRun = []
macroTreeRoot = macrosDialog.macroTree.invisibleRootItem()
for itemIndex in range(macroTreeRoot.childCount()):
item = macroTreeRoot.child(itemIndex)
if item.isSelected():
macrosToRun.append(item.text(0))
if macrosToRun:
currentScene = self.centralWidget().tabbedPane.getCurrentScene()
selectedNodes = [self.LENTDB.getEntity(item.uid) for item in currentScene.selectedItems()
if isinstance(item, BaseNode)]
for macro in macrosToRun:
self.runMacro(macro, selectedNodes)
def runMacro(self, uid: str, selectedEntities: list) -> None:
macroDetails = self.RESOLUTIONMANAGER.macros[uid]
macroStructure = []
try:
with self.macrosLock:
for resolutionElement in macroDetails:
newResolutionUID = str(uuid4())
macroStructure.append((resolutionElement[0], newResolutionUID, resolutionElement[1]))
self.runningMacros.append(macroStructure)
firstResolutionDetails = macroStructure[0]
runResResult = self.runResolution(firstResolutionDetails[0], firstResolutionDetails[1], selectedEntities,
firstResolutionDetails[2])
if runResResult != "":
self.MESSAGEHANDLER.info(f'Running Macro: {uid}')
else:
self.setStatus("Did not run Macro.")
except Exception as e:
message = f"Failed to run Macro. Reason: {str(e)}"
self.MESSAGEHANDLER.error(message, popUp=True, exc_info=False)
self.setStatus(message)
def getClientCollectors(self) -> dict:
with self.serverCollectorsLock:
try:
clientCollectorUIDs = literal_eval(self.SETTINGS.value('Project/Server/Collectors'))
if not isinstance(clientCollectorUIDs, dict):
raise ValueError('Collectors were not saved in the correct format.')
except Exception as e:
self.MESSAGEHANDLER.error('Unable to load Collectors from Settings file.',
popUp=False,
exc_info=False)
self.MESSAGEHANDLER.debug(f'Cannot eval Project/Server/Collectors setting: {str(e)}')
clientCollectorUIDs = {}
return clientCollectorUIDs
def setClientCollectors(self, newClientCollectorsDict: dict) -> None:
if not isinstance(newClientCollectorsDict, dict):
self.MESSAGEHANDLER.error('Unable to save Collectors to Settings: Invalid format.')
self.MESSAGEHANDLER.debug(f'Collectors argument: {newClientCollectorsDict}')
self.MESSAGEHANDLER.debug(f'Collectors format: {str(type(newClientCollectorsDict))}')
else:
with self.serverCollectorsLock:
self.SETTINGS.setValue('Project/Server/Collectors', str(newClientCollectorsDict))
self.SETTINGS.save()
self.MESSAGEHANDLER.info('Client Collectors state updated.')
def receiveCollectorResultListener(self, collector_name: str, collector_uid: str, timestamp: str, results: list):
# Signal ints are 4 bytes long and signed, so we use strings to communicate timestamps.
currentCollectors = self.getClientCollectors()
currentCollectors[collector_uid] = int(timestamp)
self.setClientCollectors(currentCollectors)
self.centralWidget().tabbedPane.facilitateResolution(f'Collector {collector_uid}', results)
self.notifyUser(f"New entities discovered by collector: {collector_name}", "Collector Update")
# Server functions
def statusMessageListener(self, message: str, showPopup: bool = True) -> None:
if showPopup:
self.MESSAGEHANDLER.info(message, popUp=True)
self.setStatus(message)
def connectedToServerListener(self, server: str) -> None:
self.setStatus(f"Connected to server: {server}")
self.dockbarThree.serverStatus.updateStatus(f"Connected to server: {server}")
self.SETTINGS.setValue("Project/Server", server)
self.MESSAGEHANDLER.info("Communications successfully initialized with the server.", popUp=True)
def connectToServer(self, password: str, server: str, port: int = 3777) -> None:
self.setStatus("Connecting to server...")
if self.FCOM.isConnected():
self.disconnectFromServer()
self.MESSAGEHANDLER.info(f"Connecting to server: {server}")
try:
if self.FCOM.beginCommunications(password=password, server=server, port=port):
status = "Getting Resolutions..."
self.MESSAGEHANDLER.info(status)
self.setStatus(status)
self.FCOM.askServerForResolutions()
status = "Getting Collectors..."
self.MESSAGEHANDLER.info(status)
self.setStatus(status)
self.FCOM.askServerForCollectors(self.getClientCollectors())
status = "Getting server projects list..."
self.MESSAGEHANDLER.info(status)
self.setStatus(status)
self.FCOM.askProjectsList()
else:
self.setStatus('Failed to connect to server.')
except ConnectionRefusedError:
self.MESSAGEHANDLER.info("Connection Refused.", popUp=True)
except Exception as exception:
self.MESSAGEHANDLER.error(f"Exception occurred while connecting to server: {repr(exception)}")
self.disconnectFromServer()
return
def disconnectFromServer(self) -> None:
self.setStatus("Disconnecting from server...")
if self.FCOM.isConnected():
self.MESSAGEHANDLER.info("Closing existing connection...")
self.FCOM.close()
self.RESOLUTIONMANAGER.removeServerResolutions()
self.dockbarOne.resolutionsPalette.loadAllResolutions()
self.closeServerProjectListener()
with self.serverProjectsLock:
self.serverProjects = []
with self.serverCollectorsLock:
self.collectors = {}
self.runningCollectors = {}
self.setStatus("Disconnected from server.")
self.dockbarThree.serverStatus.updateStatus("Not connected to a server")
def addResolutionsFromServerListener(self, resolutions) -> None:
self.setStatus("Adding Resolutions from Server...")
self.RESOLUTIONMANAGER.loadResolutionsFromServer(resolutions)
self.dockbarOne.resolutionsPalette.loadAllResolutions()
def cleanServerResolutionListener(self, resolution_uid: str) -> None:
for resolutionThread in list(self.resolutions):
if resolutionThread[0].uid == resolution_uid and resolutionThread[1] is True:
if resolutionThread[0].isFinished():
resolutionThread[0].deleteLater()
self.resolutions.remove(resolutionThread)
break
def addCollectorsFromServerListener(self, server_collectors: dict, continuing_collectors_info: dict) -> None:
with self.serverCollectorsLock:
self.collectors = server_collectors
self.runningCollectors = continuing_collectors_info
def startNewCollectorListener(self, collector_category: str, collector_name: str, collector_uid: str,
collector_entities: list, collector_parameters: dict):
with self.serverCollectorsLock:
if collector_category not in self.runningCollectors:
self.runningCollectors[collector_category] = {}
if collector_name not in self.runningCollectors[collector_category]:
self.runningCollectors[collector_category][collector_name] = []
# Re-running a collector would generate duplicate info; we don't want that.
duplicateExists = any(
collectorInstance['uid'] == collector_uid
for collectorInstance in self.runningCollectors[collector_category][collector_name]
)
if not duplicateExists:
self.runningCollectors[collector_category][collector_name].append({'uid': collector_uid,
'entities': collector_entities,
'parameters': collector_parameters})
currentCollectors = self.getClientCollectors()
currentCollectors[collector_uid] = time.time_ns() // 1000
self.setClientCollectors(currentCollectors)
def stopRunningCollector(self, collectorUID: str):
"""
Stops collector. Need to be ran before setClientCollectors.
"""
with self.serverCollectorsLock:
for collectorCategory in self.runningCollectors:
for collectorName in self.runningCollectors[collectorCategory]:
for collector in self.runningCollectors[collectorCategory][collectorName]:
if collector['uid'] == collectorUID:
self.runningCollectors[collectorCategory][collectorName].remove(collector)
def receiveProjectsListListener(self, projects: list) -> None:
with self.serverProjectsLock:
self.serverProjects = projects
def receiveProjectCanvasesListListener(self, canvases: list) -> None:
with self.syncedCanvasesLock:
self.syncedCanvases = canvases
def receiveProjectDeleteListener(self, deleted_project: str) -> None:
with self.serverProjectsLock:
with contextlib.suppress(ValueError):
self.serverProjects.remove(deleted_project)
self.closeServerProjectListener()
def openServerProjectListener(self, project_name: str) -> None:
self.SETTINGS.setValue("Project/Server/Project", project_name)
self.setStatus(f"Opened Server Project: {project_name}")
self.dockbarThree.serverStatus.updateStatus("Connected to server: " +
self.SETTINGS.value("Project/Server") + " Project: " + project_name)
self.syncDatabase()
self.FCOM.askProjectCanvasesList(project_name)
self.FCOM.askServerForFileList(project_name)
self.MESSAGEHANDLER.info(f"Opened Server Project: {project_name}", popUp=True)
def closeCurrentServerProject(self) -> None:
if self.FCOM.isConnected():
current_project = self.SETTINGS.value("Project/Server/Project")
if current_project != "":
self.FCOM.closeProject(current_project)
else:
self.MESSAGEHANDLER.warning('No Server Project to close.', popUp=True)
else:
self.MESSAGEHANDLER.warning('Not connected to a Server.', popUp=True)
def closeServerProjectListener(self) -> None:
project_name = self.SETTINGS.value("Project/Server/Project")
server = self.SETTINGS.value("Project/Server")
self.SETTINGS.setValue("Project/Server/Project", "")
self.FCOM.receiveFileAbortAll(project_name)
self.FCOM.sendFileAbortAll(project_name)
self.unSyncCanvasByName()
if server is not None:
self.dockbarThree.serverStatus.updateStatus(f"Connected to server: {server}")
if project_name != "":
statusMessage = f"Closed Server project: {str(project_name)}"
self.MESSAGEHANDLER.info(statusMessage)
self.setStatus(statusMessage)
self.dockbarOne.documentsList.updateFileListFromServer(None)
with self.syncedCanvasesLock:
self.syncedCanvases = []
def openServerCanvasListener(self, canvas_name: str) -> None:
project_name = self.SETTINGS.value("Project/Server/Project")
self.setStatus(
f"Opened Server Canvas: {canvas_name} on project: {project_name}"
)
self.dockbarThree.serverStatus.updateStatus("Connected to server: " +
self.SETTINGS.value("Project/Server") + " Project: " +
project_name + " Canvas: " + canvas_name)
def closeServerCanvasListener(self, canvas_name: str) -> None:
project_name = self.SETTINGS.value("Project/Server/Project")
self.centralWidget().tabbedPane.unmarkSyncedCanvasesByName(canvas_name)
self.setStatus(
f"Closed Server Canvas: {canvas_name} on project: {project_name}"
)
self.dockbarThree.serverStatus.updateStatus("Connected to server: " +
self.SETTINGS.value("Project/Server") + " Project: " +
project_name)
def syncDatabase(self):
if self.FCOM.isConnected():
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != "":
with self.LENTDB.dbLock:
self.FCOM.syncDatabase(project_name, self.LENTDB.database)
self.MESSAGEHANDLER.info(f'Database Synced for project: {project_name}')
def syncCanvasByName(self, canvasName: str = None) -> None:
"""
If canvasName is None, syncs the current canvas.
:param canvasName:
:return:
"""
if self.FCOM.isConnected():
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
canvas_name, canvas_graph = self.centralWidget().tabbedPane.markCanvasAsSyncedByName(canvasName)
if canvas_name is not None:
self.FCOM.syncCanvasSend(project_name, canvas_name, canvas_graph)
self.MESSAGEHANDLER.info(f'Syncing Canvas: {canvas_name}')
else:
self.setStatus('No canvas to sync!')
else:
self.setStatus('Must Create or Open a project on the Server before syncing canvases.')
else:
self.setStatus("Not connected to server.")
def unSyncCurrentCanvas(self) -> None:
currentCanvasName = self.centralWidget().tabbedPane.getCurrentView().name
self.unSyncCanvasByName(currentCanvasName)
def unSyncCanvasByName(self, canvasName: str = None) -> None:
"""
If canvasName is None, unsyncs ALL canvases.
Use 'unSyncCurrentCanvas' to unsync the current canvas.
:param canvasName:
:return:
"""
self.centralWidget().tabbedPane.unmarkSyncedCanvasesByName(canvasName)
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
self.FCOM.closeCanvas(project_name, canvasName)
if canvasName is not None:
statusMessage = f'Stopped syncing Canvas: {canvasName}'
else:
statusMessage = 'Stopped syncing all Canvases.'
self.setStatus(statusMessage)
self.MESSAGEHANDLER.info(statusMessage)
def receiveSyncCanvasListener(self, canvas_name: str, canvas_nodes: dict, canvas_edges: dict) -> None:
if canvas_name in self.centralWidget().tabbedPane.canvasTabs:
canvasToSync = self.centralWidget().tabbedPane.canvasTabs[canvas_name]
if canvasToSync.synced:
canvasToSync.scene().syncCanvas(canvas_nodes, canvas_edges)
self.MESSAGEHANDLER.debug(f'Canvas {canvas_name} synced.')
def receiveSyncDatabaseListener(self, database_nodes: dict, database_edges: dict) -> None:
"""
Handles received Database Sync events sent from the server.
"""
self.LENTDB.mergeDatabases(database_nodes, database_edges, fromServer=True)
self.MESSAGEHANDLER.debug('Project database synced.')
def sendLocalCanvasUpdateToServer(self, canvas_name: str, entity_or_link_uid: Union[str, tuple]) -> None:
if not self.FCOM.isConnected():
return
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
self.FCOM.sendCanvasUpdateEvent(project_name, canvas_name, entity_or_link_uid)
self.MESSAGEHANDLER.debug(
f'Project canvas {canvas_name} send update for entity / link: {str(entity_or_link_uid)}'
)
def receiveServerCanvasUpdate(self, canvas_name: str, entity_or_link_uid: Union[str, tuple]) -> None:
scene = self.centralWidget().tabbedPane.getSceneByName(canvas_name)
if scene is not None and entity_or_link_uid:
if isinstance(entity_or_link_uid, str):
# Add Entity
if entity_or_link_uid not in scene.nodesDict:
scene.addNodeProgrammatic(entity_or_link_uid, fromServer=True)
scene.rearrangeGraph()
elif entity_or_link_uid not in scene.linksDict:
scene.addLinkProgrammatic(entity_or_link_uid, fromServer=True)
scene.rearrangeGraph()
self.MESSAGEHANDLER.debug(
f'Received update to canvas: {canvas_name} for entity / link: {str(entity_or_link_uid)}'
)
def sendLocalDatabaseUpdateToServer(self, entityJson: dict, add: int) -> None:
"""
Called by the database when a local item event occurs.
If connected to a server, the event is propagated to all other connected clients.
"""
if not self.FCOM.isConnected():
return
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
self.FCOM.sendDatabaseUpdateEvent(project_name, entityJson, add)
self.MESSAGEHANDLER.debug(
f'Sent database update to server: {entityJson} - Operation: {add}'
)
def receiveServerDatabaseUpdate(self, entityJson: dict, add: int) -> None:
# Check that the JSON received is not empty.
if entityJson:
uid = entityJson['uid']
if add == 1:
# Add item
if isinstance(uid, str):
# Add Entity
self.LENTDB.addEntity(entityJson, fromServer=True)
else:
# Add Link
self.centralWidget().tabbedPane.serverLinkAddHelper(entityJson)
elif add == 2:
# Remove item
if isinstance(uid, str):
# Remove Entity
self.centralWidget().tabbedPane.nodeRemoveAllHelper(uid)
self.LENTDB.removeEntity(uid, fromServer=True)
else:
# Remove Link
self.centralWidget().tabbedPane.linkRemoveAllHelper(uid)
self.LENTDB.removeLink(uid, fromServer=True)
elif add == 3:
# Overwrite existing link
self.centralWidget().tabbedPane.serverLinkAddHelper(entityJson, overwrite=True)
self.MESSAGEHANDLER.debug(
f'Received database update from server: {entityJson} - Operation: {add}'
)
def uploadFiles(self, items=None) -> None:
if not self.FCOM.isConnected():
self.setStatus("Not Connected to Server.")
return
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name == "":
self.setStatus("No Open Project, cannot upload files.")
self.MESSAGEHANDLER.warning("Must create or open a Server Project before uploading files.", popUp=True)
return
self.setStatus('Uploading...')
itemsToUpload = []
if items is not None:
itemsToUpload = items
else:
materialsEntities = self.RESOURCEHANDLER.getAllEntitiesInCategory('Materials')
projectFilesPath = Path(self.SETTINGS.value("Project/FilesDir"))
for item in self.centralWidget().tabbedPane.getCurrentScene().selectedItems():
itemJson = self.LENTDB.getEntity(item.uid)
itemPath = projectFilesPath / itemJson['File Path']
if itemJson.get('Entity Type') in materialsEntities and itemPath.exists() and itemPath.is_file():
itemsToUpload.append(itemJson)
if not itemsToUpload:
self.setStatus('No Materials Entities selected to upload to Server.')
self.MESSAGEHANDLER.info('To send files to the server, please select any number of Entities on the '
'current canvas. The type of each selected entity has to be one of the types '
'in the "Materials" category.', popUp=True)
return
for item in itemsToUpload:
fileDir = Path(self.SETTINGS.value("Project/FilesDir")) / item['File Path']
file_name = item[self.RESOURCEHANDLER.getPrimaryFieldForEntityType(item['Entity Type'])]
if file_name in [uploadingFileName.getFileName()
for uploadingFileName in self.dockbarOne.documentsList.uploadingFileWidgets]:
continue
if file_name in [uploadedFileName.getFileName()
for uploadedFileName in self.dockbarOne.documentsList.uploadedFileWidgets]:
continue
self.dockbarOne.documentsList.addUploadingFileToList(file_name)
self.FCOM.sendFile(project_name, file_name, fileDir)
def receiveAbortUploadOfFiles(self, file_name: Union[str, None]):
"""
Remove file_name from documents widget on dockbar one.
If None is passed instead of a string, all currently uploading files are removed.
:param file_name:
:return:
"""
if file_name is not None:
self.dockbarOne.documentsList.finishUploadingFile(file_name)
else:
for uploadingFile in list(self.dockbarOne.documentsList.uploadingFileWidgets):
self.dockbarOne.documentsList.takeTopLevelItem(
self.dockbarOne.documentsList.indexOfTopLevelItem(uploadingFile))
self.dockbarOne.documentsList.uploadingFileWidgets.remove(uploadingFile)
def receiveFileListListener(self, fileList: list) -> None:
self.MESSAGEHANDLER.debug(f'Received file list from server: {fileList}')
self.dockbarOne.documentsList.updateFileListFromServer(fileList)
def fileUploadFinishedListener(self, file_name: str) -> None:
self.MESSAGEHANDLER.debug(f'File upload finished: {file_name}')
self.dockbarOne.documentsList.finishUploadingFile(file_name)
def abortUpload(self, file_name: str) -> None:
if self.FCOM.isConnected():
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
self.FCOM.sendFileAbort(project_name, file_name)
def downloadFiles(self, items=None) -> None:
if not self.FCOM.isConnected():
self.setStatus("Not Connected to Server.")
return
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name == "":
self.setStatus("No open Server Project, cannot download files.")
self.MESSAGEHANDLER.warning("Must create or open a Server Project before downloading files.", popUp=True)
return
self.setStatus('Downloading...')
itemsToDownload = []
if items is not None:
itemsToDownload = items
else:
materialsEntities = self.RESOURCEHANDLER.getAllEntitiesInCategory('Materials')
projectFilesPath = Path(self.SETTINGS.value("Project/FilesDir"))
for item in self.centralWidget().tabbedPane.getCurrentScene().selectedItems():
itemJson = self.LENTDB.getEntity(item.uid)
itemPath = projectFilesPath / itemJson['File Path']
# Do not download files that already exist.
if itemJson.get('Entity Type') in materialsEntities and not itemPath.exists():
itemsToDownload.append(itemJson)
if not itemsToDownload:
self.setStatus('No Materials Entities selected to download from Server.')
self.MESSAGEHANDLER.info('To download files from the server, please select any number of Entities on the '
'current canvas. The type of each selected entity has to be one of the types '
'in the "Materials" category.', popUp=True)
return
for item in itemsToDownload:
fileDir = Path(self.SETTINGS.value("Project/FilesDir")) / item['File Path']
file_name = item[self.RESOURCEHANDLER.getPrimaryFieldForEntityType(item['Entity Type'])]
self.FCOM.receiveFile(project_name, file_name, fileDir)
def abortDownload(self, file_name: str) -> None:
if self.FCOM.isConnected():
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
self.FCOM.receiveFileAbort(project_name, file_name)
def getSummaryOfDocument(self, document_name: Union[str, None]) -> None:
if self.FCOM.isConnected():
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name != '':
if document_name is None:
selectedDocuments = self.dockbarOne.documentsList.selectedItems()
if len(selectedDocuments) > 0:
document_name = selectedDocuments[0].getFileName()
else:
self.setStatus("Must upload and select document before obtaining summary.")
self.MESSAGEHANDLER.warning("Must upload and select document from 'Files Loaded' section "
"before getting summary.", popUp=True)
self.FCOM.askServerForFileSummary(project_name, document_name)
else:
self.setStatus("Not Connected to Server.")
self.MESSAGEHANDLER.info('Cannot get summary of documents: Not working on a Server Project.',
popUp=True)
else:
self.setStatus("No currently open Server Project.")
self.MESSAGEHANDLER.info('Cannot get summary of documents: Not connected to a Server.', popUp=True)
def receiveSummaryOfDocument(self, document_name: str, summary: str):
self.centralWidget().setDocTitleAndContents(document_name, summary)
def askQuestion(self) -> None:
question = self.dockbarTwo.oracle.questionSection.text()
if not self.FCOM.isConnected():
self.setStatus("Not Connected to Server.")
self.dockbarTwo.oracle.answerSection.setPlainText(
"Not connected to Server - Question Answering is disabled."
)
else:
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name == "":
self.setStatus("No Open Project, Question Answering system cannot be used.")
self.dockbarTwo.oracle.answerSection.setPlainText(
"No open server Project - Question Answering is disabled."
)
else:
self.dockbarTwo.oracle.answerSection.setPlainText("Calculating Answer...")
self.MESSAGEHANDLER.info(f'Asking question: {question}')
self.FCOM.askQuestion(project_name, question,
int(self.SETTINGS.value("Project/Question Answering Reader Value")),
int(self.SETTINGS.value("Project/Question Answering Retriever Value")),
int(self.SETTINGS.value("Project/Number of Answers Returned")))
self.setStatus("Asked Question")
def questionAnswerListener(self, response: list) -> None:
textAns = "No Answer."
answerCount = len(response)
if answerCount != 0:
textAns = ""
for answerIndex in range(answerCount):
answer = response[answerIndex]
if answer['answer']:
textAns += f"Answer {str(answerIndex + 1)}: {answer['answer']}\n\n" \
f"Context: ...{answer['context']}...\n\n" \
f"Document Used: {answer['doc']}\n\n"
else:
textAns += f"Answer {str(answerIndex + 1)}: No Answer\n\n"
self.dockbarTwo.oracle.answerSection.setPlainText(textAns)
self.setStatus("Answered Question")
def sendChatMessage(self, chat_message: str) -> None:
if not self.FCOM.isConnected():
self.setStatus("Not Connected to Server.")
else:
project_name = self.SETTINGS.value("Project/Server/Project")
if project_name == "":
self.setStatus("No open Project.")
else:
self.FCOM.sendTextMessage(project_name, chat_message)
def receiveChatMessage(self, message: str) -> None:
self.dockbarThree.chatBox.receiveMessage(message)
def openUpdateWindow(self, updateOverride: bool = False) -> None:
updaterWindow = UpdaterWindow(self, self.UPDATEMANAGER, updateOverride)
updaterWindow.exec()
def initializeLayout(self) -> None:
# Have to show the window here, otherwise Seg Fault.
self.show()
self.restoreGeometry(QtCore.QByteArray(self.SETTINGS.value("MainWindow/Geometry")))
self.restoreState(QtCore.QByteArray(self.SETTINGS.value("MainWindow/WindowState")))
self.addDockWidget(QtCore.Qt.DockWidgetArea.LeftDockWidgetArea,
self.dockbarOne)
self.addDockWidget(QtCore.Qt.DockWidgetArea.RightDockWidgetArea,
self.dockbarTwo)
self.addDockWidget(QtCore.Qt.DockWidgetArea.BottomDockWidgetArea,
self.dockbarThree)
self.addToolBar(self.primaryToolbar)
self.setMenuBar(MenuBar.MenuBar(self))
# Set the main window title and show it to the user.
self.resetMainWindowTitle()
iconPath = Path(self.SETTINGS.value('Program/BaseDir')) / 'Icon.ico'
appIcon = QtGui.QIcon(str(iconPath))
self.setWindowIcon(appIcon)
self.trayIcon = QtWidgets.QSystemTrayIcon(appIcon, self)
# Whether the icon is shown or not depends on the Desktop environment.
self.trayIcon.show()
# Autosave approximately once every ten minutes.
# Margin of error: 500 ms.
self.saveTimer.start(600000)
# Moved this here so the software doesn't crash if there are a ton of nodes.
self.centralWidget().tabbedPane.open()
self.RESOLUTIONMANAGER.loadMacros()
# Creating default 'Home' tab, if no tabs exist.
if len(self.centralWidget().tabbedPane.canvasTabs) == 0:
self.centralWidget().tabbedPane.createHomeTab()
self.MESSAGEHANDLER.info(f"Project {self.SETTINGS.value('Project/Name', 'Untitled')} opened.")
if self.SETTINGS.value("Program/Usage/First Time Start", 'true') == 'true':
FirstTimeUseDialog().exec()
# Technically no need to give the full dict, but it doesn't hurt.
self.MODULEMANAGER.installSource({'URI': 'https://github.com/AccentuSoft/LinkScope-Module-Packs',
'Remote': True, 'Auth': None, 'SchemaType': 3, 'UUID': str(uuid4())})
self.SETTINGS.setValue("Program/Usage/First Time Start", 'false')
if self.UPDATEMANAGER.isUpdateAvailable():
self.openUpdateWindow(True)
def __init__(self):
super(MainWindow, self).__init__()
self.linkingNodes = False
# Create or open project
nW = NewOrOpenWidget(self)
nW.exec()
if not nW.createProject and nW.openProject is None:
sys.exit()
self.SETTINGS = SettingsObject.SettingsObject()
if nW.createProject:
self.SETTINGS.setValue(
"Project/BaseDir", str(Path(nW.pDir.text()).joinpath(nW.pName.text())))
self.SETTINGS.setValue("Project/FilesDir",
str(Path(self.SETTINGS.value("Project/BaseDir")).joinpath("Project Files")))
self.SETTINGS.setValue("Project/Name", nW.pName.text())
self.SETTINGS.setValue("Program/BaseDir", dirname(abspath(getsourcefile(lambda: 0))))
try:
Path(self.SETTINGS.value("Project/BaseDir")
).mkdir(0o700, parents=False, exist_ok=False)
Path(self.SETTINGS.value("Project/FilesDir")
).mkdir(0o700, parents=False, exist_ok=False)
except FileExistsError:
QtWidgets.QMessageBox.warning(self, 'Cannot create project',
'Cannot save project to an existing directory. '
'Please choose a unique name.',
QtWidgets.QMessageBox.StandardButton.Ok)
return
except FileNotFoundError:
QtWidgets.QMessageBox.warning(self, 'Cannot create project',
'Cannot save project into a non-existing parent directory. '
'Please create the required parent directories and try again.',
QtWidgets.QMessageBox.StandardButton.Ok)
return
elif nW.openProject is not None:
projectDir = Path(nW.openProject).parent.absolute()
with open(nW.openProject, "rb") as projectDirFile:
self.SETTINGS.load(load(projectDirFile))
# Re-set file path related settings in case the project or the software was moved.
self.SETTINGS.setValue("Program/BaseDir", dirname(abspath(getsourcefile(lambda: 0))))
self.SETTINGS.setValue("Project/BaseDir", str(projectDir))
self.SETTINGS.setValue("Project/FilesDir", str(projectDir / "Project Files"))
self.MESSAGEHANDLER = MessageHandler.MessageHandler(self)
self.MESSAGEHANDLER.info(f'Starting LinkScope Client, Version {self.SETTINGS.value("Program/Version", "N/A")}')
self.UPDATEMANAGER = UpdateManager(self)
self.URLMANAGER = URLManager.URLManager(self)
self.dockbarThree = DockBarThree.DockBarThree(self)
self.RESOURCEHANDLER = ResourceHandler.ResourceHandler(self)
self.LENTDB = EntityDB.EntitiesDB(self)
self.RESOLUTIONMANAGER = ResolutionManager.ResolutionManager(self)
self.MODULEMANAGER = ModuleManager.ModulesManager(self)
self.FCOM = FrontendCommunicationsHandler.CommunicationsHandler(self)
self.LQLWIZARD = LQLQueryBuilder(self)
# Have the project auto-save on regular intervals by default.
self.saveTimer = QtCore.QTimer(self)
self.saveTimer.timeout.connect(self.autoSaveProject)
self.saveTimer.setSingleShot(False)
self.saveTimer.setTimerType(QtCore.Qt.TimerType.VeryCoarseTimer)
self.syncedCanvases = []
self.syncedCanvasesLock = threading.Lock()
self.serverProjects = []
self.serverProjectsLock = threading.Lock()
self.resolutions = []
self.serverCollectorsLock = threading.Lock()
self.collectors = {}
self.runningCollectors = {}
self.cycleExtractionThreads = []
self.macrosLock = threading.Lock()
self.runningMacros = []
entityTextFont = QtGui.QFont(self.SETTINGS.value("Program/Graphics/Entity Text Font Type"),
int(self.SETTINGS.value("Program/Graphics/Entity Text Font Size")),
int(self.SETTINGS.value("Program/Graphics/Entity Text Font Boldness")))
entityTextBrush = QtGui.QBrush(self.SETTINGS.value("Program/Graphics/Entity Text Color"))
linkTextFont = QtGui.QFont(self.SETTINGS.value("Program/Graphics/Link Text Font Type"),
int(self.SETTINGS.value("Program/Graphics/Link Text Font Size")),
int(self.SETTINGS.value("Program/Graphics/Link Text Font Boldness")))
linkTextBrush = QtGui.QBrush(self.SETTINGS.value("Program/Graphics/Link Text Color"))
zoomHide = - int(self.SETTINGS.value("Program/Graphics/Label Fade Scroll Distance"))
self.setCentralWidget(CentralPane.WorkspaceWidget(self,
self.MESSAGEHANDLER,
self.URLMANAGER,
self.LENTDB,
self.RESOURCEHANDLER,
entityTextFont,
entityTextBrush,
linkTextFont,
linkTextBrush,
zoomHide
))
self.facilitateResolutionSignalListener.connect(self.centralWidget().tabbedPane.facilitateResolution)
self.loadModules()
# Sort the resolutions alphabetically.
self.RESOLUTIONMANAGER.resolutions = dict(sorted(self.RESOLUTIONMANAGER.resolutions.items()))
self.dockbarOne = DockBarOne.DockBarOne(
self,
self.RESOLUTIONMANAGER,
self.RESOURCEHANDLER,
self.LENTDB)
self.dockbarTwo = DockBarTwo.DockBarTwo(self,
self.RESOURCEHANDLER,
self.LENTDB)
self.primaryToolbar = ToolBarOne.ToolBarOne('Primary Toolbar', self)
self.trayIcon = None
# Cannot specify icon if notification spawned from signal.
self.notifyUserSignalListener.connect(self.notifyUser)
# Allow threads to set statuses.
self.statusBarSignalListener.connect(self.setStatus)
# Allow threads to issue warnings.
self.warningSignalListener.connect(self.MESSAGEHANDLER.warning)
# Allow threads to issue errors.
self.errorSignalListener.connect(self.MESSAGEHANDLER.error)
# Connect resolution results to macro execution function
self.runningMacroResolutionFinishedSignalListener.connect(self.resolutionFinishedMacrosListener)
self.initializeLayout()
class CreateOrOpenCanvas(QtWidgets.QDialog):
def __init__(self, parent, isConnectedToNetwork=False, syncedCanvases=None):
super().__init__(parent=parent)
if syncedCanvases is None:
syncedCanvases = []
dialogLayout = QtWidgets.QGridLayout()
self.setLayout(dialogLayout)
self.setWindowTitle("Create New Canvas Or Open Existing")
self.setMinimumSize(400, 200)
createCanvasTitleLabel = QtWidgets.QLabel("Create New Canvas")
createCanvasTitleLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
createCanvasTitleLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
createCanvasNameLabel = QtWidgets.QLabel("New Canvas Name:")
self.createCanvasTextbox = QtWidgets.QLineEdit("")
createCanvasButton = QtWidgets.QPushButton("Create New Canvas")
createCanvasButton.clicked.connect(self.confirmCreateCanvas)
dialogLayout.addWidget(createCanvasTitleLabel, 0, 0, 1, 4)
dialogLayout.addWidget(createCanvasNameLabel, 1, 0, 1, 2)
dialogLayout.addWidget(self.createCanvasTextbox, 1, 2, 1, 2)
dialogLayout.addWidget(createCanvasButton, 2, 1, 1, 2)
# Add some space in the layout
spacer = QtWidgets.QLabel()
dialogLayout.addWidget(spacer, 3, 1)
openExistingCanvasTitleLabel = QtWidgets.QLabel("Open Existing Canvas")
openExistingCanvasTitleLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
openExistingCanvasTitleLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
openExistingCanvasNameLabel = QtWidgets.QLabel("Canvas Name:")
self.openExistingCanvasDropdown = QtWidgets.QComboBox()
self.openExistingCanvasDropdown.setEditable(False)
self.openExistingCanvasDropdown.SizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents)
tabbedPane = self.parent().centralWidget().tabbedPane
canvasTabs = tabbedPane.canvasTabs
existingTabNames = [
tabbedPane.tabText(tabIndex) for tabIndex in range(tabbedPane.count())
]
openExistingCanvasButton = QtWidgets.QPushButton('Open Existing Canvas')
openExistingCanvasButton.clicked.connect(self.confirmOpenExistingCanvas)
canvasesToOpen = [tabName for tabName in canvasTabs if tabName not in existingTabNames]
self.openExistingCanvasDropdown.addItems(canvasesToOpen)
if not canvasesToOpen:
self.openExistingCanvasDropdown.setEnabled(False)
openExistingCanvasButton.setEnabled(False)
dialogLayout.addWidget(openExistingCanvasTitleLabel, 4, 0, 1, 4)
dialogLayout.addWidget(openExistingCanvasNameLabel, 5, 0, 1, 2)
dialogLayout.addWidget(self.openExistingCanvasDropdown, 5, 2, 1, 2)
dialogLayout.addWidget(openExistingCanvasButton, 6, 1, 1, 2)
# Add some space in the layout
spacer = QtWidgets.QLabel()
dialogLayout.addWidget(spacer, 7, 1)
openServerCanvasTitleLabel = QtWidgets.QLabel("Open Canvas From Server")
openServerCanvasTitleLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
openServerCanvasTitleLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
openServerCanvasNameLabel = QtWidgets.QLabel("Canvas Name:")
self.openServerCanvasDropdown = QtWidgets.QComboBox()
self.openServerCanvasDropdown.setEditable(False)
self.openServerCanvasDropdown.SizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents)
self.openServerCanvasDropdown.addItems(syncedCanvases)
openServerCanvasButton = QtWidgets.QPushButton("Open Canvas from Server")
openServerCanvasButton.clicked.connect(self.confirmOpenServerCanvas)
if not isConnectedToNetwork or syncedCanvases == []:
self.openServerCanvasDropdown.setEnabled(False)
openServerCanvasButton.setEnabled(False)
dialogLayout.addWidget(openServerCanvasTitleLabel, 8, 0, 1, 4)
dialogLayout.addWidget(openServerCanvasNameLabel, 9, 0, 1, 2)
dialogLayout.addWidget(self.openServerCanvasDropdown, 9, 2, 1, 2)
dialogLayout.addWidget(openServerCanvasButton, 10, 1, 1, 2)
self.canvasName = ""
def confirmCreateCanvas(self):
self.canvasName = self.createCanvasTextbox.text()
if self.parent().centralWidget().tabbedPane.addCanvas(self.canvasName):
self.accept()
else:
self.parent().MESSAGEHANDLER.warning("A Canvas with that name already exists!", popUp=True)
def confirmOpenServerCanvas(self):
self.canvasName = self.openServerCanvasDropdown.currentText()
if self.parent().centralWidget().tabbedPane.addCanvas(self.canvasName):
self.parent().syncCanvasByName(self.canvasName)
self.accept()
else:
self.parent().MESSAGEHANDLER.warning("A Canvas with that name already exists!", popUp=True)
def confirmOpenExistingCanvas(self):
self.canvasName = self.openExistingCanvasDropdown.currentText()
self.parent().centralWidget().tabbedPane.showTab(self.canvasName)
self.accept()
class NewOrOpenWidget(QtWidgets.QDialog):
"""
If self.openProject is not None, then the user opened a project.
If it is none, check self.pName (Project Name) and self.pDir
(Project Directory).
"""
def __init__(self, parent):
super().__init__(parent=parent)
newOrOpenLayout = QtWidgets.QGridLayout()
self.setLayout(newOrOpenLayout)
self.setWindowTitle("Create New Project Or Open Existing")
self.setMinimumSize(700, 300)
self.setObjectName("settingsWidget")
# New Project
self.createProject = False
newProjectNameLabel = QtWidgets.QLabel("New Project")
newProjectNameLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
pProjectPathLabel = QtWidgets.QLabel("Project Path:")
pDirLabel = QtWidgets.QLabel("Directory")
pDirLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
pNameLabel = QtWidgets.QLabel("Name")
pNameLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
pSlashLabel = QtWidgets.QLabel("/")
pSlashLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.pName = QtWidgets.QLineEdit("Untitled")
self.pDir = QtWidgets.QLineEdit("")
self.pDir.setMinimumSize(400, 10)
self.pDir.setReadOnly(True)
pDirButton = QtWidgets.QPushButton("Select Directory...")
pDirButton.clicked.connect(self.selectProjectDirectory)
newProjectConfirm = QtWidgets.QPushButton("Create")
newProjectConfirm.clicked.connect(self.createNewProject)
newOrOpenLayout.addWidget(newProjectNameLabel, 0, 0, 1, 4)
newOrOpenLayout.addWidget(pDirLabel, 1, 1)
newOrOpenLayout.addWidget(pNameLabel, 1, 3)
newOrOpenLayout.addWidget(pProjectPathLabel, 2, 0)
newOrOpenLayout.addWidget(pSlashLabel, 2, 2)
newOrOpenLayout.addWidget(self.pDir, 2, 1)
newOrOpenLayout.addWidget(self.pName, 2, 3)
newOrOpenLayout.addWidget(pDirButton, 3, 1)
newOrOpenLayout.addWidget(newProjectConfirm, 3, 3)
# Existing
self.openProject = None
openProjectFileLabel = QtWidgets.QLabel("Open Project File")
openProjectFileLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
openProjectButton = QtWidgets.QPushButton("Open...")
openProjectButton.clicked.connect(self.openFilename)
newOrOpenLayout.addWidget(openProjectFileLabel, 5, 0, 1, 4)
newOrOpenLayout.addWidget(openProjectButton, 6, 0, 1, 4)
def createNewProject(self):
errorMessage = ""
newProjectDir = Path(self.pDir.text()) / self.pName.text()
if not access(self.pDir.text(), R_OK | W_OK):
errorMessage += "Chosen Directory is not Readable and Writeable!\n"
if newProjectDir.exists():
errorMessage += "Chosen Project Path already exists!\n"
if not errorMessage:
self.createProject = True
self.close()
else:
showError = QtWidgets.QMessageBox(self)
showError.setText(errorMessage)
showError.setStandardButtons(QtWidgets.QMessageBox().StandardButton.Ok)
showError.setDefaultButton(QtWidgets.QMessageBox().StandardButton.Ok)
showError.exec()
def selectProjectDirectory(self):
filename = QtWidgets.QFileDialog.getExistingDirectory(self,
"Select Project Directory",
str(Path.home()),
options=QtWidgets.QFileDialog.Option.DontUseNativeDialog)
if access(filename, R_OK | W_OK):
self.pDir.setText(filename)
def openFilename(self):
filename = QtWidgets.QFileDialog.getOpenFileName(self,
"Open Project File",
str(Path.home()),
"LinkScope Projects(*.linkscope)",
options=QtWidgets.QFileDialog.Option.DontUseNativeDialog)
if filename[0].endswith('.linkscope') and access(filename[0], R_OK | W_OK):
self.openProject = filename[0]
self.close()
class GraphicsEditDialog(QtWidgets.QDialog):
def __init__(self, settingsObject, resourceHandler):
super(GraphicsEditDialog, self).__init__()
self.setModal(True)
self.setMaximumWidth(850)
self.setMinimumWidth(600)
self.setMaximumHeight(600)
self.setMinimumHeight(400)
self.settings = settingsObject
editDialogLayout = QtWidgets.QGridLayout()
self.setLayout(editDialogLayout)
scrollArea = QtWidgets.QScrollArea()
scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setWidgetResizable(True)
scrollContainer = QtWidgets.QWidget()
scrollLayout = QtWidgets.QVBoxLayout()
scrollContainer.setLayout(scrollLayout)
scrollArea.setWidget(scrollContainer)
editDialogLayout.addWidget(scrollArea, 0, 0, 2, 2)
resolutionCategoryWidget = QtWidgets.QWidget()
self.resolutionCategoryLayout = SettingsCategoryLayout(supportsDeletion=False)
resolutionCategoryWidget.setLayout(self.resolutionCategoryLayout)
resolutionCategoryLabel = QtWidgets.QLabel('Graphics Settings')
resolutionCategoryLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Shadow.Raised | QtWidgets.QFrame.Shape.Panel)
resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
scrollLayout.addWidget(resolutionCategoryLabel)
scrollLayout.addWidget(resolutionCategoryWidget)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
editDialogLayout.addWidget(confirmButton, 2, 1, 1, 1)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
editDialogLayout.addWidget(cancelButton, 2, 0, 1, 1)
self.settingsTextboxes = []
self.settingsValueTextboxes = []
self.newSettings = {}
etfSettingTextbox = SettingsIntegerEditTextBox(
int(self.settings.value("Program/Graphics/Entity Text Font Size")),
"ETF",
50, 5)
self.settingsValueTextboxes.append(etfSettingTextbox)
self.resolutionCategoryLayout.addRow("Entity Text Font Size", etfSettingTextbox)
ltfSettingTextbox = SettingsIntegerEditTextBox(
int(self.settings.value("Program/Graphics/Link Text Font Size")),
"LTF",
50, 5)
self.settingsValueTextboxes.append(ltfSettingTextbox)
self.resolutionCategoryLayout.addRow("Link Text Font Size", ltfSettingTextbox)
labelFadeSettingTextbox = SettingsIntegerEditTextBox(
int(self.settings.value("Program/Graphics/Label Fade Scroll Distance")),
"LF",
12, 0)
self.settingsValueTextboxes.append(labelFadeSettingTextbox)
self.resolutionCategoryLayout.addRow("Label Fade Threshold", labelFadeSettingTextbox)
etcSettingWidget = QtWidgets.QWidget()
etcSettingLayout = QtWidgets.QHBoxLayout()
etcSettingWidget.setLayout(etcSettingLayout)
etcSettingTextbox = SettingsEditTextBox(self.settings.value("Program/Graphics/Entity Text Color"), "ETC")
etcSettingTextbox.setToolTip('Hex color code')
etcSettingLayout.addWidget(etcSettingTextbox, 5)
etcSettingPalettePrompt = QtWidgets.QPushButton(QtGui.QIcon(resourceHandler.getIcon("colorPicker")),
"Pick Colour")
etcSettingPalettePrompt.clicked.connect(self.runEntityColorPicker)
etcSettingLayout.addWidget(etcSettingPalettePrompt)
self.settingsTextboxes.append(etcSettingTextbox)
self.resolutionCategoryLayout.addRow("Entity Text Color", etcSettingWidget)
ltcSettingWidget = QtWidgets.QWidget()
ltcSettingLayout = QtWidgets.QHBoxLayout()
ltcSettingWidget.setLayout(ltcSettingLayout)
ltcSettingTextbox = SettingsEditTextBox(self.settings.value("Program/Graphics/Link Text Color"), "LTC")
ltcSettingTextbox.setToolTip('Hex color code')
ltcSettingLayout.addWidget(ltcSettingTextbox, 5)
ltcSettingPalettePrompt = QtWidgets.QPushButton(QtGui.QIcon(resourceHandler.getIcon("colorPicker")),
"Pick Colour")
ltcSettingPalettePrompt.clicked.connect(self.runLinkColorPicker)
ltcSettingLayout.addWidget(ltcSettingPalettePrompt)
self.settingsTextboxes.append(ltcSettingTextbox)
self.resolutionCategoryLayout.addRow("Link Text Color", ltcSettingWidget)
def runEntityColorPicker(self):
color = QtWidgets.QColorDialog.getColor(QtGui.QColor(self.settings.value("Program/Graphics/Entity Text Color")),
title="Select New Entity Text Color")
if color.isValid():
self.settingsTextboxes[0].setText(color.name())
def runLinkColorPicker(self):
color = QtWidgets.QColorDialog.getColor(QtGui.QColor(self.settings.value("Program/Graphics/Link Text Color")),
title="Select New Link Text Color")
if color.isValid():
self.settingsTextboxes[1].setText(color.name())
def accept(self) -> None:
# Cannot delete these values.
for settingTextbox in self.settingsTextboxes:
key = settingTextbox.settingsKey
value = settingTextbox.text()
self.newSettings[key] = value
for settingsValueTextbox in self.settingsValueTextboxes:
key = settingsValueTextbox.settingsKey
value = settingsValueTextbox.value()
self.newSettings[key] = value
super(GraphicsEditDialog, self).accept()
class ProgramEditDialog(QtWidgets.QDialog):
def __init__(self, mainWindowObject):
super(ProgramEditDialog, self).__init__()
self.setModal(True)
self.setMaximumWidth(850)
self.setMinimumWidth(600)
self.setMaximumHeight(600)
self.setMinimumHeight(400)
self.mainWindow = mainWindowObject
resolutionsEditDialog = QtWidgets.QGridLayout()
self.setLayout(resolutionsEditDialog)
scrollArea = QtWidgets.QScrollArea()
scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setWidgetResizable(True)
scrollContainer = QtWidgets.QWidget()
scrollLayout = QtWidgets.QVBoxLayout()
scrollContainer.setLayout(scrollLayout)
scrollArea.setWidget(scrollContainer)
resolutionsEditDialog.addWidget(scrollArea, 0, 0, 2, 2)
resolutionCategoryWidget = QtWidgets.QWidget()
self.resolutionCategoryLayout = SettingsCategoryLayout(supportsDeletion=False)
resolutionCategoryWidget.setLayout(self.resolutionCategoryLayout)
resolutionCategoryLabel = QtWidgets.QLabel('Program Settings')
resolutionCategoryLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Shadow.Raised | QtWidgets.QFrame.Shape.Panel)
resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
scrollLayout.addWidget(resolutionCategoryLabel)
scrollLayout.addWidget(resolutionCategoryWidget)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
resolutionsEditDialog.addWidget(confirmButton, 2, 1, 1, 1)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
resolutionsEditDialog.addWidget(cancelButton, 2, 0, 1, 1)
self.settingsTextboxes = []
self.newSettings = {}
self.settingsSingleChoice = []
self.torProfileTextBox = None
for setting, settingValue in self.mainWindow.SETTINGS.getGroupSettings('Program/').items():
keyName = setting.split('Program/', 1)[1]
# Don't allow users to mess with sensitive settings.
if keyName not in ["BaseDir", "Version", "Macros"] and len(setting.split('/')) == 2:
# A bit redundant to do it this way, but it'll be cleaner if / when more settings are added.
if keyName == "Graph Layout":
settingSingleChoice = SettingsEditSingleChoice(['dot', 'sfdp', 'neato'],
settingValue,
setting)
self.settingsSingleChoice.append(settingSingleChoice)
self.resolutionCategoryLayout.addRow(keyName, settingSingleChoice)
elif keyName == "TOR Profile Location":
self.torProfileTextBox = SettingsEditTextBox(settingValue, setting)
self.settingsTextboxes.append(self.torProfileTextBox)
torValueWidget = QtWidgets.QWidget()
torValueWidgetLayout = QtWidgets.QHBoxLayout()
torValueWidget.setLayout(torValueWidgetLayout)
torValueWidgetLayout.addWidget(self.torProfileTextBox)
fileSelectButton = QtWidgets.QPushButton('Select Folder')
fileSelectButton.clicked.connect(self.getTORProfileLocation)
torValueWidgetLayout.addWidget(fileSelectButton)
self.resolutionCategoryLayout.addRow(keyName, torValueWidget)
else:
settingTextbox = SettingsEditTextBox(settingValue, setting)
self.settingsTextboxes.append(settingTextbox)
self.resolutionCategoryLayout.addRow(keyName, settingTextbox)
def getTORProfileLocation(self):
getTORProfileDialog = QtWidgets.QFileDialog()
getTORProfileDialog.setOption(QtWidgets.QFileDialog.Option.DontUseNativeDialog, True)
getTORProfileDialog.setViewMode(QtWidgets.QFileDialog.ViewMode.List)
getTORProfileDialog.setFileMode(QtWidgets.QFileDialog.FileMode.Directory)
getTORProfileDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptMode.AcceptOpen)
getTORProfileDialog.setDirectory(str(Path.home()))
if getTORProfileDialog.exec():
try:
filePath = Path(getTORProfileDialog.selectedFiles()[0])
# Basic sanity check.
if not (filePath / 'sessionstore-backups').exists():
self.mainWindow.MESSAGEHANDLER.error('Path selected is not a TOR browser profile.\n'
'TOR operations may not work.', exc_info=False)
self.torProfileTextBox.setText(str(filePath.absolute()))
except Exception:
self.mainWindow.MESSAGEHANDLER.info('TOR profile path selection cancelled.')
def accept(self) -> None:
for settingTextbox in self.settingsTextboxes:
key = settingTextbox.settingsKey
value = settingTextbox.text()
isDeleted = settingTextbox.keyDeleted
self.newSettings[key] = (value, isDeleted)
for settingSingleChoice in self.settingsSingleChoice:
key = settingSingleChoice.settingsKey
value = [option.text() for option in settingSingleChoice.options if option.isChecked()][0]
isDeleted = settingSingleChoice.keyDeleted
self.newSettings[key] = (value, isDeleted)
super(ProgramEditDialog, self).accept()
class ResolutionsEditDialog(QtWidgets.QDialog):
def __init__(self, settingsObject):
super(ResolutionsEditDialog, self).__init__()
self.setModal(True)
self.setMaximumWidth(850)
self.setMinimumWidth(600)
self.setMaximumHeight(600)
self.setMinimumHeight(400)
self.settings = settingsObject
resolutionsEditDialog = QtWidgets.QGridLayout()
self.setLayout(resolutionsEditDialog)
scrollArea = QtWidgets.QScrollArea()
scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setWidgetResizable(True)
scrollContainer = QtWidgets.QWidget()
scrollLayout = QtWidgets.QVBoxLayout()
scrollContainer.setLayout(scrollLayout)
scrollArea.setWidget(scrollContainer)
resolutionsEditDialog.addWidget(scrollArea, 0, 0, 2, 2)
resolutionCategoryWidget = QtWidgets.QWidget()
self.resolutionCategoryLayout = SettingsCategoryLayout()
resolutionCategoryWidget.setLayout(self.resolutionCategoryLayout)
resolutionCategoryLabel = QtWidgets.QLabel('Resolutions Settings')
resolutionCategoryLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Shadow.Raised | QtWidgets.QFrame.Shape.Panel)
resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
scrollLayout.addWidget(resolutionCategoryLabel)
scrollLayout.addWidget(resolutionCategoryWidget)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
resolutionsEditDialog.addWidget(confirmButton, 2, 1, 1, 1)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
resolutionsEditDialog.addWidget(cancelButton, 2, 0, 1, 1)
self.settingsTextboxes = []
self.newSettings = {}
for setting, settingValue in self.settings.getGroupSettings('Resolutions/').items():
keyName = setting.split('Resolutions/', 1)[1]
settingTextbox = SettingsEditTextBox(settingValue, setting)
self.settingsTextboxes.append(settingTextbox)
self.resolutionCategoryLayout.addRow(keyName, settingTextbox)
def accept(self) -> None:
for settingTextbox in self.settingsTextboxes:
key = settingTextbox.settingsKey
value = settingTextbox.text()
isDeleted = settingTextbox.keyDeleted
self.newSettings[key] = (value, isDeleted)
super(ResolutionsEditDialog, self).accept()
class LoggingSettingsDialog(QtWidgets.QDialog):
def __init__(self, settingsObject):
super(LoggingSettingsDialog, self).__init__()
self.setModal(True)
self.setMaximumWidth(850)
self.setMinimumWidth(600)
self.setMaximumHeight(600)
self.setMinimumHeight(400)
self.settings = settingsObject
loggingSettingsLayout = QtWidgets.QGridLayout()
self.setLayout(loggingSettingsLayout)
scrollArea = QtWidgets.QScrollArea()
scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setWidgetResizable(True)
scrollContainer = QtWidgets.QWidget()
scrollLayout = QtWidgets.QVBoxLayout()
scrollContainer.setLayout(scrollLayout)
scrollArea.setWidget(scrollContainer)
loggingSettingsLayout.addWidget(scrollArea, 0, 0, 2, 2)
# Settings Categories: Program, Logging, Project, Resolution, Other.
loggingCategoryWidget = QtWidgets.QWidget()
self.loggingCategoryLayout = SettingsCategoryLayout(supportsDeletion=False)
loggingCategoryWidget.setLayout(self.loggingCategoryLayout)
loggingCategoryLabel = QtWidgets.QLabel('Logging Settings')
loggingCategoryLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
loggingCategoryLabel.setFrameStyle(QtWidgets.QFrame.Shadow.Raised | QtWidgets.QFrame.Shape.Panel)
loggingCategoryLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
scrollLayout.addWidget(loggingCategoryLabel)
scrollLayout.addWidget(loggingCategoryWidget)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
loggingSettingsLayout.addWidget(confirmButton, 2, 1, 1, 1)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
loggingSettingsLayout.addWidget(cancelButton, 2, 0, 1, 1)
self.settingsTextboxes = []
self.newSettings = {}
for setting, settingValue in self.settings.getGroupSettings('Logging/').items():
settingTextbox = SettingsEditTextBox(settingValue, setting)
self.settingsTextboxes.append(settingTextbox)
keyName = setting.split('Logging/', 1)[1]
self.loggingCategoryLayout.addRow(keyName, settingTextbox)
def accept(self) -> None:
for settingTextbox in self.settingsTextboxes:
key = settingTextbox.settingsKey
value = settingTextbox.text()
isDeleted = settingTextbox.keyDeleted
self.newSettings[key] = (value, isDeleted)
super(LoggingSettingsDialog, self).accept()
class ProjectEditDialog(QtWidgets.QDialog):
def __init__(self, settingsObject):
super(ProjectEditDialog, self).__init__()
self.setModal(True)
self.setMaximumWidth(850)
self.setMinimumWidth(600)
self.setMaximumHeight(600)
self.setMinimumHeight(400)
self.settings = settingsObject
editDialogLayout = QtWidgets.QGridLayout()
self.setLayout(editDialogLayout)
scrollArea = QtWidgets.QScrollArea()
scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scrollArea.setWidgetResizable(True)
scrollContainer = QtWidgets.QWidget()
scrollLayout = QtWidgets.QVBoxLayout()
scrollContainer.setLayout(scrollLayout)
scrollArea.setWidget(scrollContainer)
editDialogLayout.addWidget(scrollArea, 0, 0, 2, 2)
resolutionCategoryWidget = QtWidgets.QWidget()
self.resolutionCategoryLayout = SettingsCategoryLayout(supportsDeletion=False)
resolutionCategoryWidget.setLayout(self.resolutionCategoryLayout)
resolutionCategoryLabel = QtWidgets.QLabel('Project Settings')
resolutionCategoryLabel.setFont(QtGui.QFont("Mono", 13, QtGui.QFont.Weight.Bold))
resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Shadow.Raised | QtWidgets.QFrame.Shape.Panel)
resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
scrollLayout.addWidget(resolutionCategoryLabel)
scrollLayout.addWidget(resolutionCategoryWidget)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
editDialogLayout.addWidget(confirmButton, 2, 1, 1, 1)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
editDialogLayout.addWidget(cancelButton, 2, 0, 1, 1)
self.settingsTextboxes = []
self.settingsSingleChoice = []
self.newSettings = {}
for setting, settingValue in self.settings.getGroupSettings('Project/').items():
keyName = setting.split('Project/', 1)[1]
if keyName == 'Resolution Result Grouping Threshold' or \
setting == 'Number of Answers Returned' or \
setting == 'Question Answering Retriever Value' or \
setting == 'Question Answering Reader Value':
settingTextbox = SettingsEditTextBox(settingValue, setting)
self.settingsTextboxes.append(settingTextbox)
self.resolutionCategoryLayout.addRow(keyName, settingTextbox)
elif keyName == 'Symlink or Copy Materials':
settingSingleChoice = SettingsEditSingleChoice(['Symlink', 'Copy'], settingValue, setting)
self.settingsSingleChoice.append(settingSingleChoice)
self.resolutionCategoryLayout.addRow(keyName, settingSingleChoice)
def accept(self) -> None:
for settingTextbox in self.settingsTextboxes:
key = settingTextbox.settingsKey
value = settingTextbox.text()
isDeleted = settingTextbox.keyDeleted
self.newSettings[key] = (value, isDeleted)
for settingSingleChoice in self.settingsSingleChoice:
key = settingSingleChoice.settingsKey
value = [option.text() for option in settingSingleChoice.options if option.isChecked()][0]
isDeleted = settingSingleChoice.keyDeleted
self.newSettings[key] = (value, isDeleted)
super(ProjectEditDialog, self).accept()
class SettingsEditTextBox(QtWidgets.QLineEdit):
def __init__(self, contents: str, settingsKey: str):
super(SettingsEditTextBox, self).__init__(contents)
self.settingsKey = settingsKey
self.keyDeleted = False
self.setToolTip("Edit the contents to change the setting's value.")
class SettingsIntegerEditTextBox(QtWidgets.QSpinBox):
def __init__(self, contents: int, settingsKey: str, maxVal: int, minVal: int):
super(SettingsIntegerEditTextBox, self).__init__()
self.setMaximum(maxVal)
self.setMinimum(minVal)
self.setValue(contents)
self.settingsKey = settingsKey
self.keyDeleted = False
self.setToolTip("Edit the contents to change the setting's value.")
class SettingsEditSingleChoice(QtWidgets.QWidget):
def __init__(self, contents, currentSettingValue, settingsKey):
super(SettingsEditSingleChoice, self).__init__()
self.settingsKey = settingsKey
self.keyDeleted = False
self.setLayout(QtWidgets.QVBoxLayout())
self.layout().setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.options = []
for userChoice in contents:
radioButton = QtWidgets.QRadioButton(userChoice)
self.options.append(radioButton)
if userChoice == currentSettingValue:
radioButton.setChecked(True)
self.layout().addWidget(radioButton)
class SettingsCategoryLayout(QtWidgets.QVBoxLayout):
def __init__(self, supportsDeletion: bool = True):
super(SettingsCategoryLayout, self).__init__()
self.supportsDeletion = supportsDeletion
def addRow(self, labelText: str, valueWidget):
rowWidget = QtWidgets.QWidget()
rowWidgetLayout = QtWidgets.QVBoxLayout()
rowWidget.setLayout(rowWidgetLayout)
keyLabel = QtWidgets.QLabel(labelText)
keyLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
rowWidgetLayout.addWidget(keyLabel)
rowWidgetLayout.addWidget(valueWidget)
if self.supportsDeletion:
deleteKeyButton = SettingsDeleteKeyButton('Delete', valueWidget, rowWidget)
rowWidgetLayout.addWidget(deleteKeyButton)
self.addWidget(rowWidget)
class SettingsDeleteKeyButton(QtWidgets.QPushButton):
def __init__(self, text: str, settingsItem, rowItem: QtWidgets.QWidget):
super(SettingsDeleteKeyButton, self).__init__(text)
self.settingsItem = settingsItem
self.rowItem = rowItem
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
self.settingsItem.keyDeleted = True
self.rowItem.hide()
class FindEntityOnCanvasDialog(QtWidgets.QDialog):
def __init__(self, primaryFieldsList: list, regex: bool):
super(FindEntityOnCanvasDialog, self).__init__()
self.setModal(True)
self.setMinimumWidth(400)
if regex:
self.setWindowTitle('Regex Find Entity or Link')
else:
self.setWindowTitle('Find Entity or Link')
findLabel = QtWidgets.QLabel('Find:')
findLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.findInput = QtWidgets.QLineEdit('')
self.findInput.setPlaceholderText('Type the primary field value to search for')
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
autoCompleter = QtWidgets.QCompleter(primaryFieldsList)
if regex:
# Doesn't actually work in python, as far as I can see.
# Throws error: Unhandled QCompleter::filterMode flag is used.
# autoCompleter.setFilterMode(QtGui.Qt.MatchRegularExpression)
pass
else:
autoCompleter.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)
autoCompleter.setFilterMode(QtCore.Qt.MatchFlag.MatchContains)
self.findInput.setCompleter(autoCompleter)
findLayout = QtWidgets.QGridLayout()
self.setLayout(findLayout)
findLayout.addWidget(findLabel, 0, 0, 1, 1)
findLayout.addWidget(self.findInput, 0, 1, 1, 1)
# Adding the confirm button first so that it's what is activated when someone presses Enter.
findLayout.addWidget(confirmButton, 1, 1, 1, 1)
findLayout.addWidget(cancelButton, 1, 0, 1, 1)
class FindEntityOfTypeOnCanvasDialog(QtWidgets.QDialog):
def __init__(self, mainWindowObject, entityTypesDict: dict, regex: bool):
super(FindEntityOfTypeOnCanvasDialog, self).__init__()
self.setModal(True)
self.setMinimumWidth(400)
if regex:
self.setWindowTitle('Regex Find Entity Of Type')
else:
self.setWindowTitle('Find Entity Of Type')
typeLabel = QtWidgets.QLabel('Entity Type:')
self.typeInput = QtWidgets.QComboBox()
self.typeInput.setEditable(False)
self.typeInput.addItems(list(entityTypesDict))
self.typeInput.currentIndexChanged.connect(self.changeSelectedType)
findLabel = QtWidgets.QLabel('Find Entity:')
findLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.findInput = QtWidgets.QLineEdit('')
self.findInput.setPlaceholderText('Type the primary field value to search for')
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
cancelButton = QtWidgets.QPushButton('Cancel')
cancelButton.clicked.connect(self.reject)
self.autoCompleters = {}
for entityType in entityTypesDict:
autoCompleter = QtWidgets.QCompleter(list(entityTypesDict[entityType]))
self.autoCompleters[entityType] = autoCompleter
if regex:
# Doesn't actually work in python, as far as I can see.
# Throws error: Unhandled QCompleter::filterMode flag is used.
# autoCompleter.setFilterMode(QtGui.Qt.MatchRegularExpression)
pass
else:
autoCompleter.setCaseSensitivity(QtCore.Qt.CaseSensitivity.CaseInsensitive)
autoCompleter.setFilterMode(QtCore.Qt.MatchFlag.MatchContains)
findLayout = QtWidgets.QGridLayout()
self.setLayout(findLayout)
findLayout.addWidget(typeLabel, 0, 0, 1, 1)
findLayout.addWidget(self.typeInput, 0, 1, 1, 1)
findLayout.addWidget(findLabel, 1, 0, 1, 1)
findLayout.addWidget(self.findInput, 1, 1, 1, 1)
# Adding the confirm button first so that it's what is activated when someone presses Enter.
findLayout.addWidget(confirmButton, 2, 1, 1, 1)
findLayout.addWidget(cancelButton, 2, 0, 1, 1)
try:
self.changeSelectedType()
except KeyError:
mainWindowObject.MESSAGEHANDLER.error('No Nodes present on current canvas.', popUp=True, exc_info=False)
return
def changeSelectedType(self):
self.findInput.setCompleter(self.autoCompleters[self.typeInput.currentText()])
class MergeEntitiesDialog(QtWidgets.QDialog):
def __init__(self, parent: MainWindow, entitiesToMerge: list):
super(MergeEntitiesDialog, self).__init__()
self.setModal(True)
self.setWindowTitle('Merge Entities')
self.parent = parent
self.entitiesToMerge = entitiesToMerge
self.primaryEntityUID = None
self.otherEntitiesUIDs = []
descriptionLabel = QtWidgets.QLabel("Select the primary entity onto which the fields of the "
"other entities will be merged. The order in which the "
"entities are in the table is the order in which they will "
"be merged. The first entity is written first. Fields with "
"non 'None' values are not overwritten.")
descriptionLabel.setWordWrap(True)
dialogLayout = QtWidgets.QGridLayout()
self.setLayout(dialogLayout)
self.entitiesTable = MergeTableWidget(0, 5, self)
self.entitiesTable.setHorizontalHeaderLabels(['Entity', 'Entity Type', 'Incoming\nLinks',
'Outgoing\nLinks', 'Shift\nPriority'])
self.radioButtonGroup = QtWidgets.QButtonGroup()
for entity in entitiesToMerge:
self.insertRow(entity)
self.entitiesTable.cellWidget(0, 0).setChecked(True)
self.entitiesTable.setColumnWidth(0, 150)
self.entitiesTable.setColumnWidth(1, 150)
self.entitiesTable.setMinimumWidth(self.entitiesTable.width())
dialogLayout.addWidget(descriptionLabel, 0, 0, 2, 2)
dialogLayout.addWidget(self.entitiesTable, 2, 0, 2, 2)
dialogLayout.setRowStretch(3, 1)
acceptButton = QtWidgets.QPushButton('Accept')
cancelButton = QtWidgets.QPushButton('Cancel')
acceptButton.clicked.connect(self.accept)
cancelButton.clicked.connect(self.reject)
dialogLayout.addWidget(cancelButton, 4, 0, 1, 1)
dialogLayout.addWidget(acceptButton, 4, 1, 1, 1)
self.adjustSize()
self.setFixedWidth(self.entitiesTable.width() + 19)
self.setMaximumHeight(700)
def insertRow(self, entityJSON):
newRowIndex = self.entitiesTable.rowCount()
self.entitiesTable.insertRow(newRowIndex)
self.entitiesTable.setRowHeight(newRowIndex, 70)
isPrimaryRadioButton = QtWidgets.QRadioButton()
isPrimaryRadioButton.setText(entityJSON[list(entityJSON)[1]])
self.entitiesTable.setCellWidget(newRowIndex, 0, isPrimaryRadioButton)
pixmapLabel = QtWidgets.QLabel()
entityPixmap = QtGui.QPixmap()
entityPixmap.loadFromData(resizePictureFromBuffer(entityJSON.get('Icon'), (40, 40)))
pixmapLabel.setPixmap(entityPixmap)
pixmapLabel.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
entityTypeWidget = QtWidgets.QWidget()
entityTypeWidgetLayout = QtWidgets.QHBoxLayout()
entityTypeWidget.setLayout(entityTypeWidgetLayout)
entityTypeWidgetLayout.addWidget(pixmapLabel)
entityTypeWidgetLayout.addWidget(QtWidgets.QLabel(entityJSON['Entity Type']))
self.entitiesTable.setCellWidget(newRowIndex, 1, entityTypeWidget)
incomingLinks = QtWidgets.QLabel(str(len(self.parent.LENTDB.getIncomingLinks(entityJSON['uid']))))
incomingLinks.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.entitiesTable.setCellWidget(newRowIndex, 2, incomingLinks)
outgoingLinks = QtWidgets.QLabel(str(len(self.parent.LENTDB.getOutgoingLinks(entityJSON['uid']))))
outgoingLinks.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.entitiesTable.setCellWidget(newRowIndex, 3, outgoingLinks)
upDownButtons = MergeTableShiftRowUpDownButtons(self.entitiesTable, entityJSON['uid'])
self.entitiesTable.setCellWidget(newRowIndex, 4, upDownButtons)
def accept(self) -> None:
for rowIndex in range(self.entitiesTable.rowCount()):
if self.entitiesTable.cellWidget(rowIndex, 0).isChecked():
self.primaryEntityUID = self.entitiesTable.cellWidget(rowIndex, 4).uid
else:
self.otherEntitiesUIDs.append(self.entitiesTable.cellWidget(rowIndex, 4).uid)
super(MergeEntitiesDialog, self).accept()
class MergeTableWidget(QtWidgets.QTableWidget):
"""
Table that presents the user the selected entities for them to merge.
The user will select a primary entity, and reorder the table such that the most important
entity values to preserve will be in entities at the top of the table.
The table has 5 columns, and indices for columns go from 0 to 4.
"""
def findRowOfShiftingWidget(self, widget: QtWidgets.QWidget):
# Always in column with index 4.
for row in range(self.rowCount()):
if self.cellWidget(row, 4) == widget:
return row
def shiftRowUp(self, widget: QtWidgets.QWidget):
rowIndex = self.findRowOfShiftingWidget(widget)
if rowIndex == 0:
return rowIndex
widgetsToShift = [
self.cellWidget(rowIndex, widgetColumnIndex)
for widgetColumnIndex in range(5)
]
self.insertRow(rowIndex - 1)
self.setRowHeight(rowIndex - 1, 70)
for widgetColumnIndex in range(5):
self.setCellWidget(rowIndex - 1, widgetColumnIndex, widgetsToShift[widgetColumnIndex])
self.removeRow(rowIndex + 1)
def shiftRowDown(self, widget: QtWidgets.QWidget) -> None:
rowIndex = self.findRowOfShiftingWidget(widget)
if rowIndex == self.rowCount() - 1:
return
widgetsToShift = [
self.cellWidget(rowIndex, widgetColumnIndex)
for widgetColumnIndex in range(5)
]
self.insertRow(rowIndex + 2)
self.setRowHeight(rowIndex + 2, 70)
for widgetColumnIndex in range(5):
self.setCellWidget(rowIndex + 2, widgetColumnIndex, widgetsToShift[widgetColumnIndex])
self.removeRow(rowIndex)
class MergeTableShiftRowUpDownButtons(QtWidgets.QWidget):
def __init__(self, parent, uid) -> None:
super(MergeTableShiftRowUpDownButtons, self).__init__(parent=parent)
self.setLayout(QtWidgets.QVBoxLayout())
self.layout().setContentsMargins(0, 0, 0, 0)
self.uid = uid
upButton = QtWidgets.QPushButton('^')
downButton = QtWidgets.QPushButton('v')
upButton.clicked.connect(lambda: self.shiftRow(True))
downButton.clicked.connect(lambda: self.shiftRow(False))
self.layout().addWidget(upButton)
self.layout().addWidget(downButton)
def shiftRow(self, shiftUp: bool) -> None:
if shiftUp:
self.parent().parent().shiftRowUp(self)
else:
self.parent().parent().shiftRowDown(self)
class SplitEntitiesDialog(QtWidgets.QDialog):
def __init__(self, parent: MainWindow, entityToSplit: dict) -> None:
super(SplitEntitiesDialog, self).__init__()
self.setModal(True)
self.parent = parent
self.entityToSplitPrimaryField = entityToSplit[list(entityToSplit)[1]]
self.setWindowTitle('Split Entities')
# One is used to store primary fields, the other primary fields + the links that the user selected
# for that primary field. This takes up more memory, but less processing time.
self.splitEntities = []
self.splitEntitiesWithLinks = []
incomingLinks = list(parent.LENTDB.getIncomingLinks(entityToSplit['uid']))
outgoingLinks = list(parent.LENTDB.getOutgoingLinks(entityToSplit['uid']))
self.allLinks = [parent.LENTDB.getLink(linkUID) for linkUID in incomingLinks + outgoingLinks]
descriptionLabel = QtWidgets.QLabel("Use the '+' and '-' buttons to create and remove entities. For each "
"resolution column in each entity row, set the checkbox to selected "
"if you want the corresponding entity to have the resolution on the "
"selected column.")
descriptionLabel.setWordWrap(True)
dialogLayout = QtWidgets.QGridLayout()
self.setLayout(dialogLayout)
self.entitiesTable = QtWidgets.QTableWidget(0, len(self.allLinks) + 1, self)
# Resize columns to give a bit more space to each column.
for columnIndex in range(self.entitiesTable.columnCount()):
self.entitiesTable.setColumnWidth(columnIndex, 150)
horizontalHeaderLabels = []
for incomingLink in incomingLinks:
linkNode = parent.LENTDB.getEntity(incomingLink[0])
linkNodeName = linkNode[list(linkNode)[1]]
horizontalHeaderLabels.append('Incoming From:\n' + linkNodeName)
for outgoingLink in outgoingLinks:
linkNode = parent.LENTDB.getEntity(outgoingLink[1])
linkNodeName = linkNode[list(linkNode)[1]]
horizontalHeaderLabels.append('Outgoing To:\n' + linkNodeName)
self.entitiesTable.setHorizontalHeaderLabels(['Entity Name:'] + horizontalHeaderLabels)
self.insertRow(self.entityToSplitPrimaryField)
self.entitiesTable.setMinimumWidth(self.entitiesTable.width())
self.addSplitEntity = QtWidgets.QPushButton('+')
self.addSplitEntity.clicked.connect(self.insertRow)
self.removeSplitEntity = QtWidgets.QPushButton('-')
self.removeSplitEntity.clicked.connect(self.removeRow)
dialogLayout.addWidget(descriptionLabel, 0, 0, 2, 4)
dialogLayout.addWidget(self.entitiesTable, 2, 1, 4, 3)
dialogLayout.addWidget(self.addSplitEntity, 2, 0, 1, 1)
dialogLayout.addWidget(self.removeSplitEntity, 3, 0, 1, 1)
dialogLayout.setRowStretch(4, 1)
dialogLayout.setColumnStretch(1, 1)
dialogLayout.setColumnStretch(2, 1)
dialogLayout.setColumnStretch(3, 1)
acceptButton = QtWidgets.QPushButton('Accept')
cancelButton = QtWidgets.QPushButton('Cancel')
acceptButton.clicked.connect(self.accept)
cancelButton.clicked.connect(self.reject)
dialogLayout.addWidget(cancelButton, 6, 0, 1, 2)
dialogLayout.addWidget(acceptButton, 6, 2, 1, 2)
self.adjustSize()
self.setMaximumHeight(700)
self.setMinimumWidth(min(self.entitiesTable.width(), 700))
def insertRow(self, entityPrimaryField: str = '') -> None:
# Do not allow splitting into more than 5 entities at a time for performance reasons.
if self.entitiesTable.rowCount() == 5:
self.parent.MESSAGEHANDLER.info('Maximum number of entities to split into reached.')
return
newRowIndex = self.entitiesTable.rowCount()
self.entitiesTable.insertRow(newRowIndex)
self.entitiesTable.setItem(newRowIndex, 0, QtWidgets.QTableWidgetItem(entityPrimaryField))
self.entitiesTable.setFocus()
for count, link in enumerate(self.allLinks, start=1):
selectResolution = QtWidgets.QCheckBox(link['Resolution'])
selectResolution.linkUID = link['uid']
self.entitiesTable.setCellWidget(newRowIndex, count, selectResolution)
def removeRow(self) -> None:
self.entitiesTable.removeRow(self.entitiesTable.rowCount() - 1)
def accept(self) -> None:
for newEntityRow in range(self.entitiesTable.rowCount()):
entityName = self.entitiesTable.item(newEntityRow, 0).text()
if entityName == '':
self.parent.MESSAGEHANDLER.info('Cannot split into entities with blank primary fields.',
popUp=True)
return self.clearSplitEntitiesHelper()
elif self.parent.LENTDB.doesEntityExist(entityName) and entityName != self.entityToSplitPrimaryField:
self.parent.MESSAGEHANDLER.info("Entity primary field value specified already exists:\n" + entityName,
popUp=True)
return self.clearSplitEntitiesHelper()
elif entityName in self.splitEntities:
self.parent.MESSAGEHANDLER.info("Duplicate primary field value specified:\n" + entityName,
popUp=True)
return self.clearSplitEntitiesHelper()
self.splitEntities.append(entityName)
allLinkUIDsForEntity = [
self.allLinks[columnIndex - 1]
for columnIndex in range(1, self.entitiesTable.columnCount())
if self.entitiesTable.cellWidget(
newEntityRow, columnIndex
).isChecked()
]
self.splitEntitiesWithLinks.append((entityName, allLinkUIDsForEntity))
# Clear out this list, no more need for it
self.splitEntities = []
super(SplitEntitiesDialog, self).accept()
def clearSplitEntitiesHelper(self):
self.splitEntities = []
self.splitEntitiesWithLinks = []
return
class FirstTimeUseDialog(QtWidgets.QDialog):
def __init__(self):
super().__init__()
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
self.setWindowTitle('Welcome to LinkScope!')
firstTimeLabel = QtWidgets.QLabel(
'### Greetings and welcome to LinkScope!\n\n**If this is your first time using this software**, we recommend '
'going through [our introductory blog post](https://accentusoft.com/tutorials/first-steps-with-linkscope-client/) to '
'get a feel for how the software works, and what you can do with it. [Our YouTube Channel](https://www.youtube.com/channel/UC8h9Vde1OdezdC2cJ1nEUcw) '
'has a Playlist called "How To in Less than 30 Seconds" that shows you how to perform common and '
'not-so-common operations within LinkScope.\n\n**First things first**, we recommend you get started by installing '
'the Module Packs that you need via the **Modules** tab on the top menu bar, and selecting **View Modules Manager**.')
firstTimeLabel.setTextFormat(QtCore.Qt.TextFormat.MarkdownText)
firstTimeLabel.setTextInteractionFlags(QtCore.Qt.TextInteractionFlag.TextBrowserInteraction)
firstTimeLabel.setOpenExternalLinks(True)
firstTimeLabel.setWordWrap(True)
firstTimeLabel.setMinimumWidth(435)
confirmButton = QtWidgets.QPushButton('Confirm')
confirmButton.clicked.connect(self.accept)
layout.addWidget(firstTimeLabel)
layout.addWidget(confirmButton)
layout.setStretch(0, 100)
class ExtractCyclesThread(QtCore.QThread):
cyclesSignal = QtCore.Signal(list, str)
def __init__(self, tempGraph: nx.DiGraph, nodesList: list, canvasName: str):
super().__init__()
self.tempGraph = tempGraph
self.nodesList = nodesList
self.canvasName = canvasName
def run(self) -> None:
allCycles = list(nx.simple_cycles(self.tempGraph))
if not allCycles:
self.cyclesSignal.emit([], self.canvasName)
if self.nodesList:
startNode = self.nodesList[0]
else:
mostCommonDict = {}
for cycle in allCycles:
for node in cycle:
mostCommonDict[node] = mostCommonDict.get(node, 0) + 1
try:
startNode = max(mostCommonDict, key=mostCommonDict.get)
except ValueError:
# No cycles exist.
self.cyclesSignal.emit([set(), []], self.canvasName)
return
reorderedCycles = []
for cycle in allCycles:
try:
reorderedCycles.append(cycle[cycle.index(startNode):] + cycle[:cycle.index(startNode)])
except ValueError:
reorderedCycles.append(cycle)
groups = []
allElements = set()
for cycle in list(itertools.zip_longest(*reorderedCycles)):
cycleSet = set()
for cycleElement in cycle:
if cycleElement is not None:
cycleSet.add(cycleElement)
allElements.add(cycleElement)
if len(cycleSet) > 1:
groups.append(cycleSet)
self.cyclesSignal.emit([allElements, groups], self.canvasName)
if __name__ == '__main__':
# Create a graphical application
application = QtWidgets.QApplication(sys.argv)
application.setOrganizationName("AccentuSoft")
application.setApplicationName("LinkScope Client")
qdarktheme.setup_theme()
mainWindow = MainWindow()
sys.exit(application.exec())