diff --git a/Icon.ico b/Icon.ico new file mode 100644 index 0000000..b19198d Binary files /dev/null and b/Icon.ico differ diff --git a/Nexus.py b/Nexus.py new file mode 100644 index 0000000..f281c06 --- /dev/null +++ b/Nexus.py @@ -0,0 +1,2904 @@ +#!/usr/bin/env python3 + +# Load modules +import sys +import tempfile +import threading + +import networkx as nx +from ast import literal_eval +from uuid import uuid4 +from shutil import move +from inspect import getsourcefile +from os import listdir, access, R_OK, W_OK +from os.path import abspath, dirname +from pickle import load +from pathlib import Path +from datetime import datetime +from typing import Union +from PySide6 import QtWidgets, QtGui, QtCore + +import Core.SettingsObject +from Core import MessageHandler, SettingsObject +from Core import ResourceHandler +from Core import ReportGeneration +from Core import EntityDB +from Core import ResolutionManager +from Core import URLManager +from Core import FrontendCommunicationsHandler +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 import Stylesheets +from Core.Interface.Entity import BaseNode, BaseConnector, GroupNode +from Core.PathHelper import is_path_exists_or_creatable_portable + + +# Main Window of Application +class MainWindow(QtWidgets.QMainWindow): + + # 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) -> Core.SettingsObject.SettingsObject: + return self.SETTINGS + + # What happens when the software is closed + def closeEvent(self, event) -> None: + # Save the window settings + self.SETTINGS.setValue("MainWindow/Geometry", self.saveGeometry()) + self.SETTINGS.setValue("MainWindow/WindowState", self.saveState()) + if self.FCOM.isConnected(): + self.FCOM.close() + self.SETTINGS.setValue("Project/Server/Project", "") + self.saveProject() + super(MainWindow, self).closeEvent(event) + + def saveProject(self) -> None: + # noinspection PyBroadException + try: + self.SETTINGS.save() + self.LENTDB.save() + self.centralWidget().tabbedPane.save() + except Exception: + self.MESSAGEHANDLER.error("Could not Save Project.", exc_info=True) + self.setStatus("Project Saved.", 3000) + self.MESSAGEHANDLER.info('Project Saved') + + 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.DontUseNativeDialog, True) + saveAsDialog.setViewMode(QtWidgets.QFileDialog.List) + saveAsDialog.setFileMode(QtWidgets.QFileDialog.AnyFile) + saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + saveAsExec = saveAsDialog.exec() + if not saveAsExec: + 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.setWindowTitle("Nexus - " + self.SETTINGS.get('Project/Name', 'Untitled')) + self.saveProject() + self.setStatus('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.DontUseNativeDialog, True) + saveAsDialog.setViewMode(QtWidgets.QFileDialog.List) + saveAsDialog.setNameFilter("GraphML (*.xml)") + saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + 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("Could not export canvas to file.", popUp=True) + self.setStatus('Canvas export failed.') + + def importCanvasFromGraphML(self): + openDialog = QtWidgets.QFileDialog() + openDialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) + openDialog.setViewMode(QtWidgets.QFileDialog.List) + openDialog.setNameFilter("GraphML (*.xml)") + if openDialog.exec(): + filePath = openDialog.selectedFiles()[0] + try: + read_graphml = nx.read_graphml(filePath) + currentScene = self.centralWidget().tabbedPane.getCurrentScene() + for node in read_graphml.nodes(): + entity = self.LENTDB.getEntity(node) + if node not in currentScene.nodesDict: + if entity['Entity Type'] == 'EntityGroup': + currentScene.addNodeProgrammatic(node, entity['Child UIDs']) + else: + currentScene.addNodeProgrammatic(node) + currentScene.rearrangeGraph() + self.setStatus('Canvas imported successfully.') + except KeyError: + self.MESSAGEHANDLER.error("Cannot import canvas: One or more nodes in the graph " + "do not exist in the database.", popUp=True) + self.setStatus('Canvas import failed.') + except Exception as exc: + self.MESSAGEHANDLER.error("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 + self.LENTDB.dbLock.acquire() + currentDatabase = nx.DiGraph(self.LENTDB.database) + self.LENTDB.dbLock.release() + + for node in currentDatabase.nodes: + del currentDatabase.nodes[node]['Icon'] + + for edge in currentDatabase.edges: + currentDatabase.edges[edge]['uid'] = str(currentDatabase.edges[edge]['uid']) + + saveAsDialog = QtWidgets.QFileDialog() + saveAsDialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) + saveAsDialog.setViewMode(QtWidgets.QFileDialog.List) + saveAsDialog.setNameFilter("GraphML (*.xml)") + saveAsDialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) + 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("Could not export database to file.", popUp=True) + self.setStatus('Database export failed.') + + def importDatabaseFromGraphML(self): + openDialog = QtWidgets.QFileDialog() + openDialog.setOption(QtWidgets.QFileDialog.DontUseNativeDialog, True) + openDialog.setViewMode(QtWidgets.QFileDialog.List) + openDialog.setNameFilter("GraphML (*.xml)") + if openDialog.exec(): + try: + filePath = openDialog.selectedFiles()[0] + read_graphml = nx.read_graphml(filePath) + for node in read_graphml.nodes: + read_graphml.nodes[node]['Icon'] = self.RESOURCEHANDLER.getEntityDefaultPicture( + read_graphml.nodes[node]['Entity Type']) + for edge in read_graphml.edges: + read_graphml.edges[edge]['uid'] = literal_eval(read_graphml.edges[edge]['uid']) + self.LENTDB.mergeDatabases(read_graphml, fromServer=False) + self.dockbarOne.existingEntitiesPalette.loadEntities() + self.LENTDB.resetTimeline() + self.setStatus('Database imported successfully.') + except Exception as exc: + self.MESSAGEHANDLER.error("Could not import database from file.", popUp=True) + self.setStatus('Database import failed.') + + def generateReport(self): + wizard = ReportWizard(self) + wizard.show() + + def renameProjectPromptName(self) -> None: + newName, confirm = QtWidgets.QInputDialog.getText(self, + 'Rename Project', + 'New Name:', + QtWidgets.QLineEdit.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(oldName + '.nexus') + oldProjectFile.unlink(missing_ok=True) + + self.setWindowTitle("Nexus - " + self.SETTINGS.get('Project/Name', 'Untitled')) + self.saveProject() + self.setStatus('Project Renamed to: ' + newName) + + def addCanvas(self) -> None: + # Create or open canvas + connected = False + if self.FCOM.isConnected(): + connected = True + + with self.syncedCanvasesLock: + availableSyncedCanvases = self.syncedCanvases + newCanvasPopup = CreateOrOpenCanvas(self, connected, availableSyncedCanvases) + newCanvasPopup.exec() + + def toggleWorldDoc(self) -> None: + if self.centralWidget() is not None: + self.centralWidget().toggleLayout() + + def toggleLinkingMode(self) -> None: + if self.centralWidget().tabbedPane.getCurrentScene().appendingToGroup: + self.setStatus('Cannot create manual link at this moment: Currently adding entities to group.') + return + if self.linkingNodes: + self.centralWidget().tabbedPane.enableAllTabs() + self.centralWidget().tabbedPane.getCurrentScene().linking = False + self.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) + self.linkingNodes = False + else: + self.centralWidget().tabbedPane.disableAllTabsExceptCurrent() + self.centralWidget().tabbedPane.getCurrentScene().linking = True + self.centralWidget().tabbedPane.getCurrentScene().itemsToLink = [] + self.centralWidget().tabbedPane.getCurrentScene().clearSelection() + self.setCursor(QtGui.QCursor(QtCore.Qt.CrossCursor)) + self.linkingNodes = True + + def deleteSpecificEntity(self, itemUID: str) -> None: + for canvas in self.centralWidget().tabbedPane.canvasTabs: + scene = self.centralWidget().tabbedPane.canvasTabs[canvas].scene() + if itemUID in scene.nodesDict: + scene.removeNode(scene.nodesDict[itemUID]) + self.LENTDB.removeEntity(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) + + def setGroupAppendMode(self, enable: bool) -> None: + if self.centralWidget().tabbedPane.getCurrentScene().linking: + return + if enable: + self.centralWidget().tabbedPane.disableAllTabsExceptCurrent() + self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) + else: + self.centralWidget().tabbedPane.enableAllTabs() + self.setCursor(QtGui.QCursor(QtCore.Qt.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: + """ + self.centralWidget().tabbedPane.getCurrentScene().clearSelection() + currentCanvasGraph = self.centralWidget().tabbedPane.getCurrentScene().sceneGraph + nodes = [x for x in currentCanvasGraph.nodes() + if currentCanvasGraph.out_degree(x) == 0 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 nodes: + item.setSelected(True) + + def selectNonIsolatedNodes(self) -> None: + """ + Select all nodes with at least one link going into or out of them. + :return: + """ + self.centralWidget().tabbedPane.getCurrentScene().clearSelection() + currentCanvasGraph = self.centralWidget().tabbedPane.getCurrentScene().sceneGraph + nodes = [x for x in currentCanvasGraph.nodes() + if currentCanvasGraph.out_degree(x) > 0 or 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 nodes: + item.setSelected(True) + + def findShortestPath(self) -> None: + """ + Find the shortest path between two nodes, if it exists. + Exactly two nodes must be selected. + :return: + """ + endPoints = [item.uid for item in self.centralWidget().tabbedPane.getCurrentScene().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 = self.centralWidget().tabbedPane.getCurrentScene().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: + self.setStatus('No path between the selected nodes was found.') + else: + self.centralWidget().tabbedPane.getCurrentScene().clearSelection() + for item in [node for node in self.centralWidget().tabbedPane.getCurrentScene().items() + if isinstance(node, BaseNode)]: + if item.uid in shortestPath: + item.setSelected(True) + linksToSelect = [(a, b) for a, b in zip(shortestPath, shortestPath[1:])] + for linkItem in [link for link in self.centralWidget().tabbedPane.getCurrentScene().items() + if isinstance(link, BaseConnector)]: + if linkItem.uid in linksToSelect: + linkItem.setSelected(True) + self.setStatus('Shortest path found.') + + def findEntityOrLinkOnCanvas(self) -> None: + currentScene = self.centralWidget().tabbedPane.getCurrentScene() + currentUIDs = [item.uid for item in currentScene.items() if isinstance(item, BaseNode) + or isinstance(item, BaseConnector)] + entityPrimaryFields = [] + for uid in currentUIDs: + item = self.LENTDB.getEntity(uid) + if item is None: + item = self.LENTDB.getLink(uid) + if item is None: + continue + entityPrimaryFields.append(item[list(item)[1]]) + matchedPrimaryFieldsAndUIDs = [item for item in zip(currentUIDs, entityPrimaryFields)] + # Remove duplicates + entityPrimaryFields = list(set(entityPrimaryFields)) + matchedPrimaryFieldsAndUIDs = list(set(matchedPrimaryFieldsAndUIDs)) + findPrompt = FindEntityOnCanvasDialog(entityPrimaryFields) + promptReturnCode = findPrompt.exec() + + if promptReturnCode: + uidsToSelect = [] + findText = findPrompt.findInput.text() + for item in matchedPrimaryFieldsAndUIDs: + if item[1].startswith(findText): + uidsToSelect.append(item[0]) + + currentScene.clearSelection() + for item in [linkOrEntity for linkOrEntity in currentScene.items() + if isinstance(linkOrEntity, BaseNode) or isinstance(linkOrEntity, BaseConnector)]: + if item.uid in uidsToSelect: + item.setSelected(True) + if len(uidsToSelect) == 1: + self.centralWidget().tabbedPane.getCurrentView().centerViewportOnNode(uidsToSelect[0]) + + 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: + linksToAdd.append([incomingLink[0], primaryEntity['uid'], incomingLink['Resolution'], + incomingLink['Notes']]) + for outgoingLink in allOutgoingLinks: + linksToAdd.append([primaryEntity['uid'], outgoingLink[1], outgoingLink['Resolution'], + outgoingLink['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 len(validEntityToSplit) == 0: + 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 + + 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 = {} + for field in entityToSplit: + if field != 'uid' and field != entityToSplitPrimaryFieldKey: + newEntity[field] = entityToSplit[field] + newEntity[entityToSplitPrimaryFieldKey] = newEntityWithLinks[0] + newEntity = self.LENTDB.addEntity(newEntity) + + for link in newEntityWithLinks[1]: + newLink = {} + for field in link: + newLink[field] = link[field] + 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 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) + settingsConfirm = settingsDialog.exec() + + if settingsConfirm: + # Save new settings + newSettings = settingsDialog.newSettings + for key in newSettings: + newSettingValue = newSettings[key] + if newSettingValue[1]: + # Delete key + self.SETTINGS.pop(key) + elif newSettingValue[0] != '': + # Do not allow blank settings. + if key == 'Project/Resolution Result Grouping Threshold' or \ + key == 'Project/Number of Answers Returned' or \ + key == 'Project/Question Answering Retriever Value' or \ + key == 'Project/Question Answering Reader Value': + try: + int(newSettingValue[1]) + self.SETTINGS.setValue(key, newSettingValue[0]) + except ValueError: + pass + elif newSettingValue[0] == 'Copy' or newSettingValue[0] == 'Symlink': + self.SETTINGS.setValue(key, newSettingValue[0]) + + self.saveProject() + + def editResolutionsSettings(self) -> None: + settingsDialog = ResolutionsEditDialog(self.SETTINGS) + settingsConfirm = settingsDialog.exec() + + if settingsConfirm: + # Save new settings + newSettings = settingsDialog.newSettings + for key in newSettings: + newSettingValue = newSettings[key] + if newSettingValue[1]: + # Delete key + self.SETTINGS.pop(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) + settingsConfirm = settingsDialog.exec() + + if settingsConfirm: + # Save new settings + newSettings = settingsDialog.newSettings + for key in newSettings: + newSettingValue = newSettings[key] + if newSettingValue[1]: + # Delete key + self.SETTINGS.pop(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 loadModules(self) -> None: + """ + Loads user-defined modules from the Modules folder in the installation directory. + :return: + """ + self.RESOURCEHANDLER.loadModuleEntities() + modulesBasePath = Path(self.SETTINGS.value("Program/BaseDir")).joinpath("Modules") + for module in listdir(modulesBasePath): + self.RESOLUTIONMANAGER.loadResolutionsFromDir(modulesBasePath / module) + self.setStatus('Loaded Modules.') + + def reloadModules(self) -> None: + """ + Same as loadModules, except this one updates the GUI to show newly loaded entities and resolutions. + This is meant to be ran after the application started, in case the user wants to load a module without + closing and reopening the application. + :return: + """ + self.RESOURCEHANDLER.loadModuleEntities() + modulesBasePath = Path(self.SETTINGS.value("Program/BaseDir")).joinpath("Modules") + for module in listdir(modulesBasePath): + self.RESOLUTIONMANAGER.loadResolutionsFromDir(modulesBasePath / module) + 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 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: + 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): + for linkUID in uid: + eJson.append(self.LENTDB.getLink(linkUID)) + 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() + try: + scene.nodesDict[uid].updateLabel(label) + except KeyError: + pass + + 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() + try: + scene.linksDict[uid].updateLabel(label) + except KeyError: + pass + + 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 runResolution(self, resolution) -> None: + """ + Runs the specified resolution in another thread. + """ + self.setStatus("Running Resolution: " + resolution) + try: + category, resolution = resolution.split('/') + except ValueError: + self.MESSAGEHANDLER.error('Category name or resolution name should not contain slashes: ' + resolution) + return + + scene = self.centralWidget().tabbedPane.getCurrentScene() + items = scene.selectedItems() + resArgument = [] + for item in items: + if not isinstance(item, BaseNode): + continue + resArgument.append(self.LENTDB.getEntity(item.uid)) + + parameters = self.RESOLUTIONMANAGER.getResolutionParameters(category, resolution) + if parameters is None: + message = 'Resolution parameters not found for resolution: ' + resolution + self.MESSAGEHANDLER.error(message, popUp=True, exc_info=False) + self.setStatus(message) + return + + resolutionParameterValues = {} + resolutionUnspecifiedParameterValues = {} + for parameter in parameters: + if parameters[parameter].get('global') is True: + # Extra slash in the middle to ensure that resolutions cannot overwrite these accidentally, + # since slashes are not allowed by default on Linux or Windows. + savedParameterValue = self.SETTINGS.value('Resolutions/Global/Parameters/' + parameter) + else: + savedParameterValue = self.SETTINGS.value('Resolutions/' + resolution + '/' + parameter) + if savedParameterValue is not None: + resolutionParameterValues[parameter] = savedParameterValue + else: + resolutionUnspecifiedParameterValues[parameter] = parameters[parameter] + + # 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. + if len(items) == 0 or (0 < len(parameters) and 0 < len(resolutionUnspecifiedParameterValues)): + selectEntityList = None + uidAndPrimaryFields: list = [] + acceptableOriginTypes = None + if len(items) == 0: + acceptableOriginTypes = self.RESOLUTIONMANAGER.getResolutionOriginTypes(resolution) + 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] + + parameterSelector = ResolutionParametersSelector( + resolutionUnspecifiedParameterValues, selectEntityList, acceptableOriginTypes) + parameterSelectorConfirm = parameterSelector.exec() + + if not parameterSelectorConfirm: + self.setStatus('Resolution ' + resolution + ' aborted.') + return + else: + resolutionParameterValues.update(parameterSelector.chosenParameters) + + newParametersToSave = parameterSelector.parametersToRemember + for parameterToRemember in newParametersToSave: + # No need to update settings objects here - these values are only relevant to the mainWindow, + # since this function is where all the resolutions are ran from. + if parameters[parameterToRemember].get('global') is True: + self.SETTINGS.setValue('Resolutions/Global/Parameters/' + parameterToRemember, + newParametersToSave[parameterToRemember]) + else: + self.SETTINGS.setValue('Resolutions/' + resolution + '/' + parameterToRemember, + newParametersToSave[parameterToRemember]) + + if len(items) == 0: + selectedEntities = parameterSelector.entitySelector.selectedItems() + if len(selectedEntities) == 0: + self.setStatus('Resolution ' + resolution + + ' did not run: No entities selected.') + return + + for selectedEntity in selectedEntities: + uid = uidAndPrimaryFields[selectEntityList.index(selectedEntity.text())][0] + resArgument.append(self.LENTDB.getEntity(uid)) + + resolutionParameterValues['Project Files Directory'] = self.SETTINGS.value("Project/FilesDir") + resolutionUID = str(uuid4()) + resolutionThread = ResolutionExecutorThread( + resolution, resArgument, resolutionParameterValues, self, resolutionUID) + resolutionThread.sig.connect(self.resolutionSignalListener) + resolutionThread.start() + self.resolutions.append((resolutionThread, category == 'Server Resolutions')) + + def resolutionSignalListener(self, resolution_name: str, resolution_result: Union[list, str]) -> None: + """ + Is called by the threads created by runResolution to handle the + result, i.e. run the function that adds nodes and links. + """ + if isinstance(resolution_result, str): + self.MESSAGEHANDLER.info(f"Resolution {resolution_name} finished with status: {resolution_result}", True) + else: + self.centralWidget().tabbedPane.facilitateResolution(resolution_name, resolution_result) + + self.cleanUpLocalFinishedResolutions() + + self.setStatus("Resolution: " + resolution_name + " completed.") + + def cleanUpLocalFinishedResolutions(self) -> None: + """ + Clean out old non-server resolutions. + :return: + """ + for resolutionThread in self.resolutions: + if resolutionThread[0].isFinished() and resolutionThread[1] is False: + self.resolutions.remove(resolutionThread) + + # 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("Connected to server: " + server) + self.dockbarThree.serverStatus.updateStatus("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("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 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( + "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() + 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 executeRemoteResolution(self, resolution_name: str, resolution_entities: list, resolution_parameters: dict, + resolution_uid: str): + self.FCOM.runRemoteResolution(resolution_name, resolution_entities, resolution_parameters, resolution_uid) + + def cleanServerResolutionListener(self, resolution_uid: str) -> None: + for resolutionThread in self.resolutions: + if resolutionThread[0].uid == resolution_uid and resolutionThread[1] is True: + if resolutionThread[0].isFinished(): + self.resolutions.remove(resolutionThread) + break + + 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 openServerProjectListener(self, project_name: str) -> None: + self.SETTINGS.setValue("Project/Server/Project", project_name) + self.setStatus("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("Opened Server Project: " + project_name, popUp=True) + + def closeCurrentServerProject(self) -> None: + current_project = self.SETTINGS.value("Project/Server/Project") + if current_project != "": + self.FCOM.closeProject(current_project) + + def closeServerProjectListener(self) -> None: + project_name = self.SETTINGS.value("Project/Server/Project") + self.SETTINGS.setValue("Project/Server/Project", "") + self.FCOM.receiveFileAbortAll(project_name) + self.FCOM.sendFileAbortAll(project_name) + self.unSyncCanvasByName() + self.setStatus("Closed Server project: " + project_name) + self.dockbarThree.serverStatus.updateStatus("Connected to server: " + + self.SETTINGS.value("Project/Server")) + + self.dockbarOne.documentsList.updateFileListFromServer(None) + with self.serverProjectsLock: + self.serverProjects = [] + with self.syncedCanvasesLock: + self.syncedCanvases = [] + + def openServerCanvasListener(self, canvas_name: str) -> None: + project_name = self.SETTINGS.value("Project/Server/Project") + self.setStatus("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("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('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('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: + self.setStatus('Stopped syncing Canvas: ' + canvasName) + self.MESSAGEHANDLER.info('Stopped syncing Canvas: ' + canvasName) + + def receiveSyncCanvasListener(self, canvas_name: str, canvas_graph: str) -> None: + if canvas_name in self.centralWidget().tabbedPane.canvasTabs: + canvasToSync = self.centralWidget().tabbedPane.canvasTabs[canvas_name] + if canvasToSync.synced: + canvasToSync.scene().syncCanvas(canvas_graph) + self.MESSAGEHANDLER.debug('Canvas ' + canvas_name + ' synced.') + + def receiveSyncDatabaseListener(self, database) -> None: + """ + Handles received Database Sync events sent from the server. + :param database: + :return: + """ + self.LENTDB.mergeDatabases(database, 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('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: + 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() + else: + # Add Link + if entity_or_link_uid not in scene.linksDict: + scene.addLinkProgrammatic(entity_or_link_uid, fromServer=True) + scene.rearrangeGraph() + + self.MESSAGEHANDLER.debug('Received update to canvas: ' + canvas_name + ' for entity / link: ' + + str(entity_or_link_uid)) + + def sendLocalDatabaseUpdateToServer(self, entityJson: dict, add: bool) -> 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('Sent database update to server: ' + str(entityJson) + ' - Operation: ' + + str(add)) + + def receiveServerDatabaseUpdate(self, entityJson, add) -> None: + uid = entityJson['uid'] + if add: + # Add item + if isinstance(uid, str): + # Add Entity + self.LENTDB.addEntity(entityJson, fromServer=True) + else: + # Add Link + self.centralWidget().tabbedPane.serverLinkAddHelper(entityJson) + else: + # 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) + + self.MESSAGEHANDLER.debug('Received database update from server: ' + str(entityJson) + ' - Operation: ' + + str(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') + for item in self.centralWidget().tabbedPane.getCurrentScene().selectedItems(): + itemJson = self.LENTDB.getEntity(item.uid) + if itemJson.get('Entity Type') in materialsEntities and \ + itemJson['File Path'] != "None": + 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 = self.SETTINGS.value("Project/FilesDir") / Path(item['File Path']) + file_name = item[list(item)[1]] + 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 + if fileDir.exists(): + self.dockbarOne.documentsList.addUploadingFileToList(item[list(item)[1]]) + 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 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('Received file list from server: ' + str(fileList)) + self.dockbarOne.documentsList.updateFileListFromServer(fileList) + + def fileUploadFinishedListener(self, file_name: str) -> None: + self.MESSAGEHANDLER.debug('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 downloadFile(self, file_name: str) -> None: + saveDir = Path(self.SETTINGS.value("Project/FilesDir")) / file_name + project_name = self.SETTINGS.value("Project/Server/Project") + if not self.FCOM.isConnected(): + self.setStatus("Not Connected to a Server.") + self.MESSAGEHANDLER.info('Cannot Download file: Not connected to a Server.', popUp=True) + if project_name != '': + if saveDir.exists(): + # Do not overwrite local files. + return + self.FCOM.receiveFile(project_name, file_name) + else: + self.setStatus("No currently open Server Project.") + self.MESSAGEHANDLER.info('Cannot Download file: Not working on a Server Project.', popUp=True) + + 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.FCOM.askServerForFileSummary(project_name, document_name) + else: + self.setStatus("Not Connected to Server.") + self.MESSAGEHANDLER.info('Cannot get summary of document ' + document_name + + ': Not connected to a Server.', popUp=True) + else: + self.setStatus("No currently open Server Project.") + self.MESSAGEHANDLER.info('Cannot get summary of document ' + document_name + + ': Not working on a Server Project.', 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('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: dict) -> None: + textAns = "No Answer." + answerCount = len(response['answers']) + if answerCount != 0: + textAns = "" + for answerIndex in range(answerCount): + answer = response['answers'][answerIndex] + if answer['answer'] is not None: + textAns += "Answer " + str(answerIndex + 1) + ": " + answer['answer'] + "\n\n" + textAns += "Context: ..." + answer['context'] + "...\n\n" + textAns += "Document Used: " + answer['meta']['resourceName'][2:-1] + textAns += "\n\n" + else: + textAns += "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 initializeLayout(self) -> None: + self.setMinimumSize(1600, 960) + + self.restoreGeometry(self.SETTINGS.value("MainWindow/Geometry")) + self.restoreState(self.SETTINGS.value("MainWindow/WindowState")) + + self.addDockWidget(QtCore.Qt.LeftDockWidgetArea, + self.dockbarOne) + self.addDockWidget(QtCore.Qt.RightDockWidgetArea, + self.dockbarTwo) + self.addDockWidget(QtCore.Qt.BottomDockWidgetArea, + self.dockbarThree) + + self.setMenuBar(MenuBar.MenuBar(self)) + self.addToolBar(ToolBarOne.ToolBarOne('Primary Toolbar', self)) + + # Set the main window title and show it to the user. + self.setWindowTitle("Nexus - " + self.SETTINGS.get('Project/Name', 'Untitled')) + + self.dockbarOne.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + self.dockbarTwo.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + self.dockbarThree.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + + self.show() + + # Moved this here so the software doesn't crash if there are a ton of nodes. + self.centralWidget().tabbedPane.open() + # Creating default 'Home' tab, if no tabs exist. + if len(self.centralWidget().tabbedPane.canvasTabs) == 0: + self.centralWidget().tabbedPane.createHomeTab() + + self.setStatus("Ready") + + def __init__(self): + super(MainWindow, self).__init__() + self.linkingNodes = False + self.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + + # Create or open project + nW = NewOrOpenWidget(self) + nW.exec() + + if not nW.createProject and nW.openProject is None: + sys.exit() + + if nW.createProject: + self.SETTINGS = SettingsObject.SettingsObject() + 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.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.Ok) + return + + elif nW.openProject is not None: + projectDir = Path(nW.openProject).parent.absolute() + projectDirFile = open(nW.openProject, "rb") + self.SETTINGS = 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.RESOURCEHANDLER = ResourceHandler.ResourceHandler(self, self.MESSAGEHANDLER) + self.dockbarThree = DockBarThree.DockBarThree(self) + self.LENTDB = EntityDB.EntitiesDB(self, self.MESSAGEHANDLER, self.RESOURCEHANDLER) + self.URLMANAGER = URLManager.URLManager(self) + self.RESOLUTIONMANAGER = ResolutionManager.ResolutionManager(self, self.MESSAGEHANDLER) + self.FCOM = FrontendCommunicationsHandler.CommunicationsHandler(self) + + self.syncedCanvases = [] + self.syncedCanvasesLock = threading.Lock() + self.serverProjects = [] + self.serverProjectsLock = threading.Lock() + self.resolutions = [] + + self.RESOLUTIONMANAGER.loadResolutionsFromDir( + Path(self.SETTINGS.value("Program/BaseDir")) / "Core" / "Resolutions" / "Core") + + self.setCentralWidget(CentralPane.WorkspaceWidget(self, + self.MESSAGEHANDLER, + self.URLMANAGER, + self.LENTDB, + self.RESOURCEHANDLER)) + + self.loadModules() + + self.dockbarOne = DockBarOne.DockBarOne( + self, + self.RESOLUTIONMANAGER, + self.RESOURCEHANDLER, + self.LENTDB) + + self.dockbarTwo = DockBarTwo.DockBarTwo(self, + self.RESOURCEHANDLER, + self.LENTDB) + + self.initializeLayout() + + +class ReportWizard(QtWidgets.QWizard): + def __init__(self, parent): + super(ReportWizard, self).__init__(parent=parent) + + self.primaryFieldsList = [] + + self.addPage(InitialConfigPage(self)) + self.addPage(TitlePage(self)) + + self.addPage(SummaryPage(self)) + + self.selectedNodes = [entity for entity in + self.parent().centralWidget().tabbedPane.getCurrentScene().selectedItems() + if isinstance(entity, BaseNode)] + for selectedNode in self.selectedNodes: + # used in wizard + self.primaryField = selectedNode.labelItem.text() + self.uid = selectedNode.uid + + # used in report generation + self.primaryFieldsList.append(self.primaryField) + self.addPage(EntityPage(self)) + + self.setWizardStyle(QtWidgets.QWizard.ModernStyle) + self.setWindowTitle("Generate Report Wizard") + + self.button(QtWidgets.QWizard.FinishButton).clicked.connect(self.onFinish) + + def onFinish(self): + reportData = [] + + outgoingEntitiesForEachEntity = [] + incomingEntitiesForEachEntity = [] + outgoingEntityPrimaryFieldsForEachEntity = [] + incomingEntityPrimaryFieldsForEachEntity = [] + + entityList = [] + for pageID in self.pageIds(): + pageObject = self.page(pageID) + reportData.append(pageObject.getData()) + + for selectedNode in self.selectedNodes: + uid = selectedNode.uid + entityList.append(self.parent().LENTDB.getEntity(uid)) + outgoing = self.parent().LENTDB.getOutgoingLinks(uid) + incoming = self.parent().LENTDB.getIncomingLinks(uid) + + outgoingEntities = [] + incomingEntities = [] + outgoingNames = [] + incomingNames = [] + + for out in outgoing: + outLink = self.parent().LENTDB.getLink(out) + outgoingEntities.append(outLink) + outgoingEntityJson = self.parent().LENTDB.getEntity(outLink['uid'][1]) + outgoingNames.append(outgoingEntityJson[list(outgoingEntityJson)[1]]) + + for inc in incoming: + inLink = self.parent().LENTDB.getLink(inc) + incomingEntities.append(inLink) + incomingEntityJson = self.parent().LENTDB.getEntity(inLink['uid'][0]) + + incomingNames.append(incomingEntityJson[list(incomingEntityJson)[1]]) + + outgoingEntityPrimaryFieldsForEachEntity.append(outgoingNames) + incomingEntityPrimaryFieldsForEachEntity.append(incomingNames) + outgoingEntitiesForEachEntity.append(outgoingEntities) + incomingEntitiesForEachEntity.append(incomingEntities) + + path = Path(reportData[0].get('SavePath')) + + canvas = reportData[2].get('CanvasName') + viewPortBool = reportData[2].get('ViewPort') + + canvasPicture = self.parent().getPictureOfCanvas(canvas, viewPortBool, True) + temp_dir = tempfile.TemporaryDirectory() + canvasImagePath = Path(temp_dir.name) / 'canvas.png' + canvasPicture.save(str(canvasImagePath), "PNG") + + timelinePicture = self.parent().dockbarThree.timeWidget.takePictureOfView(False) + timelineImagePath = Path(temp_dir.name) / 'timeline.png' + timelinePicture.save(str(timelineImagePath), "PNG") + + ReportGeneration.PDFReport(str(path), reportData, outgoingEntitiesForEachEntity, incomingEntitiesForEachEntity, + entityList, canvasImagePath, timelineImagePath, self.primaryFieldsList, + incomingEntityPrimaryFieldsForEachEntity, outgoingEntityPrimaryFieldsForEachEntity) + + timelineImagePath.unlink(missing_ok=True) + self.parent().MESSAGEHANDLER.info(reportData) + + +class InitialConfigPage(QtWidgets.QWizardPage): + def __init__(self, parent=None): + super(InitialConfigPage, self).__init__(parent) + self.subtitleLabel = QtWidgets.QLabel("Path to save the report at: ") + self.savePathEdit = QtWidgets.QLineEdit() + self.setTitle(self.tr("Initial Configuration Wizard")) + + pDirButton = QtWidgets.QPushButton("Save Report As...") + pDirButton.clicked.connect(self.editPath) + + hLayout = QtWidgets.QVBoxLayout() + hLayout.addWidget(self.subtitleLabel) + hLayout.addWidget(self.savePathEdit) + hLayout.addWidget(pDirButton) + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(hLayout) + self.setLayout(layout) + + def editPath(self): + selectedPath = QtWidgets.QFileDialog.getSaveFileName(self, + "File Name to Save As", + str(Path.home()), + filter="PDF Files (*.pdf)", + options=QtWidgets.QFileDialog.DontUseNativeDialog) + selectedPath = selectedPath[0] + if selectedPath != '': + self.savePathEdit.setText(str(Path(selectedPath).absolute())) + + def getData(self): + data = {'SavePath': self.savePathEdit.text()} + return data + + +class TitlePage(QtWidgets.QWizardPage): + def __init__(self, parent=None): + super(TitlePage, self).__init__(parent) + self.inputTitleEdit = QtWidgets.QLineEdit() + self.inputSubtitleEdit = QtWidgets.QLineEdit() + self.inputAuthorsEdit = QtWidgets.QLineEdit() + self.setTitle(self.tr("Title Page Wizard")) + + titleLabel = QtWidgets.QLabel("Title: ") + subtitleLabel = QtWidgets.QLabel("Subtitle: ") + authorsLabel = QtWidgets.QLabel("Authors: ") + + hLayout = QtWidgets.QVBoxLayout() + hLayout.addWidget(titleLabel) + hLayout.addWidget(self.inputTitleEdit) + hLayout.addWidget(subtitleLabel) + hLayout.addWidget(self.inputSubtitleEdit) + hLayout.addWidget(authorsLabel) + hLayout.addWidget(self.inputAuthorsEdit) + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(hLayout) + self.setLayout(layout) + + def getData(self): + data = {'Title': self.inputTitleEdit.text(), 'Subtitle': self.inputSubtitleEdit.text(), + 'Authors': self.inputAuthorsEdit.text()} + return data + + +class SummaryPage(QtWidgets.QWizardPage): + def __init__(self, parent): + super(SummaryPage, self).__init__(parent=parent.parent()) + self.setTitle(self.tr("Summary Page Wizard")) + self.inputNotesEdit = QtWidgets.QPlainTextEdit() + self.canvasDropDownMenu = QtWidgets.QComboBox() + self.viewPortCheckBox = QtWidgets.QCheckBox('ViewPort Only') + self.viewPortCheckBox.setChecked(False) + self.canvasNames = list(self.parent().centralWidget().tabbedPane.canvasTabs.keys()) + + summaryLabel = QtWidgets.QLabel("Summary Notes: ") + + canvasLabel = QtWidgets.QLabel("Select canvas to be displayed: ") + for canvas in self.canvasNames: + self.canvasDropDownMenu.addItem(canvas) + + hLayout = QtWidgets.QVBoxLayout() + hLayout.addWidget(summaryLabel) + hLayout.addWidget(self.inputNotesEdit) + hLayout.addWidget(canvasLabel) + hLayout.addWidget(self.viewPortCheckBox) + hLayout.addWidget(self.canvasDropDownMenu) + layout = QtWidgets.QVBoxLayout() + layout.addLayout(hLayout) + self.setLayout(layout) + + def getData(self): + data = {'SummaryNotes': self.inputNotesEdit.toPlainText(), 'CanvasName': self.canvasDropDownMenu.currentText(), + 'ViewPort': self.viewPortCheckBox.isChecked()} + return data + + +class EntityPage(QtWidgets.QWizardPage): + def __init__(self, parent: ReportWizard): + super(EntityPage, self).__init__(parent=parent.parent()) + self.inputAppendixImageEdit = QtWidgets.QLineEdit() + self.appendixWidget = QtWidgets.QWidget() + self.appendixLayout = QtWidgets.QVBoxLayout() + + self.setTitle(self.tr(f"Entity Page Wizard")) + self.setMinimumSize(300, 700) + + self.entityName = parent.primaryField + self.uidPicture = parent.uid + + self.inputNotesEdit = QtWidgets.QPlainTextEdit() + self.inputImageEdit = QtWidgets.QLineEdit() + self.button = QtWidgets.QPushButton("Add...") + + self.scrolllayout = QtWidgets.QVBoxLayout() + self.scrollwidget = QtWidgets.QWidget() + + self.defaultpic = self.parent().LENTDB.getEntity(self.uidPicture).get('Icon') + + summaryLabel = QtWidgets.QLabel(f"Entity {self.entityName} Notes: ") + + imageLabel = QtWidgets.QLabel("Image Path: ") + pDirButton = QtWidgets.QPushButton("Select Image...") + pDirButton.clicked.connect(self.editPath) + pDirButton.setDisabled(True) + imageCheckBox = QtWidgets.QCheckBox('Add Custom Entity Image') + imageCheckBox.setChecked(False) + imageCheckBox.toggled.connect(pDirButton.setEnabled) + + self.button.clicked.connect(self.addSection) + + self.button.setDisabled(True) + + appendixCheckBox = QtWidgets.QCheckBox('Add Appendix') + appendixCheckBox.setChecked(False) + appendixCheckBox.toggled.connect(self.button.setEnabled) + + self.scrollwidget.setLayout(self.scrolllayout) + + scroll = QtWidgets.QScrollArea() + scroll.setWidgetResizable(True) + scroll.setWidget(self.scrollwidget) + + hLayout = QtWidgets.QVBoxLayout() + hLayout.addWidget(summaryLabel) + hLayout.addWidget(self.inputNotesEdit) + + hLayout.addWidget(imageCheckBox) + hLayout.addWidget(imageLabel) + hLayout.addWidget(self.inputImageEdit) + hLayout.addWidget(pDirButton) + + hLayout.addWidget(appendixCheckBox) + hLayout.addWidget(self.button) + + layout = QtWidgets.QVBoxLayout() + layout.addLayout(hLayout) + layout.addWidget(scroll) + + self.setLayout(layout) + + def editPath(self): + selectedPath = QtWidgets.QFileDialog().getOpenFileName(parent=self, caption='Select New Icon', + options=QtWidgets.QFileDialog.DontUseNativeDialog, + filter="Image Files (*.png *.jpg)")[0] + if selectedPath != '': + self.inputImageEdit.setText(str(Path(selectedPath).absolute())) + + def editAppendixPath(self): + selectedPath = QtWidgets.QFileDialog().getOpenFileName(parent=self, caption='Select New Icon', + options=QtWidgets.QFileDialog.DontUseNativeDialog, + filter="Image Files (*.png *.jpg)")[0] + if selectedPath != '': + self.inputAppendixImageEdit.setText(str(Path(selectedPath).absolute())) + + def addSection(self): + appendixLabelNotes = QtWidgets.QLabel("Entity Notes: ") + inputAppendixNotesEdit = QtWidgets.QPlainTextEdit() + imageAppendixLabel = QtWidgets.QLabel("Image Path: ") + appendixButton = QtWidgets.QPushButton("Select Image...") + appendixButton.clicked.connect(self.editAppendixPath) + self.appendixLayout.addWidget(appendixLabelNotes) + self.appendixLayout.addWidget(inputAppendixNotesEdit) + self.appendixLayout.addWidget(imageAppendixLabel) + self.appendixLayout.addWidget(self.inputAppendixImageEdit) + self.appendixLayout.addWidget(appendixButton) + self.appendixWidget.setLayout(self.appendixLayout) + self.scrolllayout.addWidget(self.appendixWidget) + self.button.setDisabled(True) + + def getData(self): + import re + from svglib.svglib import svg2rlg + + appendixNotes = [] + if self.inputImageEdit.text() != '': + data = {'EntityNotes': self.inputNotesEdit.toPlainText(), 'EntityImage': self.inputImageEdit.text()} + else: + if 'svg' in str(self.defaultpic): + contents = bytearray(self.defaultpic) + widthRegex = re.compile(b' width="\d*" ') + fileContents = '' + for widthMatches in widthRegex.findall(self.defaultpic): + fileContents = contents.replace(widthMatches, b' ') + heightRegex = re.compile(b' height="\d*" ') + for heightMatches in heightRegex.findall(self.defaultpic): + fileContents = contents.replace(heightMatches, b' ') + fileContents = fileContents.replace(b' 0: + self.childWidget.setCurrentIndex(currentIndex - 1) + + def accept(self) -> None: + for resolutionParameterName, resolutionParameterInput, resolutionParameterRemember in self.parametersList: + value = resolutionParameterInput.getValue() + if value == '': + msgBox = QtWidgets.QMessageBox() + msgBox.setModal(True) + QtWidgets.QMessageBox.warning(msgBox, + "Not all parameters filled in", + "Some of the required parameters for the resolution have been left blank." + " Please fill them in before proceeding.") + return + self.chosenParameters[resolutionParameterName] = value + + if resolutionParameterRemember.isChecked(): + self.parametersToRemember[resolutionParameterName] = value + + super(ResolutionParametersSelector, self).accept() + + +class StringPropertyInput(QtWidgets.QLineEdit): + + def __init__(self, placeholderText, defaultText): + super(StringPropertyInput, self).__init__() + self.setPlaceholderText(placeholderText) + if defaultText is not None: + self.setText(defaultText) + + def getValue(self): + return self.text() + + +class FilePropertyInput(QtWidgets.QLineEdit): + + def __init__(self, placeholderText, defaultText): + super(FilePropertyInput, self).__init__() + self.setPlaceholderText(placeholderText) + if defaultText is not None: + self.setText(defaultText) + self.fileDialog = QtWidgets.QFileDialog() + + def getValue(self): + return self.text() + + def mousePressEvent(self, event: QtGui.QMouseEvent) -> None: + fileChosen = self.fileDialog.getOpenFileName(self, + "Open File", + str(Path.home()), + options=QtWidgets.QFileDialog.DontUseNativeDialog) + self.setText(fileChosen[0]) + + +class SingleChoicePropertyInput(QtWidgets.QGroupBox): + + def __init__(self, optionsSet: set, defaultOption): + # Ensure that the options given are an actual set (i.e. each one is unique) + enforceOptionsSet = set(optionsSet) + super(SingleChoicePropertyInput, self).__init__(title='Option Selection') + vboxLayout = QtWidgets.QVBoxLayout() + self.setLayout(vboxLayout) + + self.options = [] + if defaultOption is None: + defaultOption = '' + + for option in enforceOptionsSet: + radioButton = QtWidgets.QRadioButton(option) + radioButton.setStyleSheet(Stylesheets.RADIO_BUTTON_STYLESHEET) + if option == defaultOption: + radioButton.setChecked(True) + else: + radioButton.setChecked(False) + self.options.append(radioButton) + vboxLayout.addWidget(radioButton) + + def getValue(self): + for option in self.options: + if option.isChecked(): + return option.text() + + return '' + + +class MultiChoicePropertyInput(QtWidgets.QGroupBox): + + def __init__(self, optionsSet: set, defaultOptions): + # Ensure that the options given are an actual set (i.e. each one is unique) + enforceOptionsSet = set(optionsSet) + super(MultiChoicePropertyInput, self).__init__(title='Option Selection') + vboxLayout = QtWidgets.QVBoxLayout() + self.setLayout(vboxLayout) + + self.options = [] + if defaultOptions is None: + defaultOptions = [] + + for option in enforceOptionsSet: + checkBox = QtWidgets.QCheckBox(option) + checkBox.setStyleSheet(Stylesheets.CHECK_BOX_STYLESHEET) + if option in defaultOptions: + checkBox.setChecked(True) + else: + checkBox.setChecked(False) + self.options.append(checkBox) + vboxLayout.addWidget(checkBox) + + def getValue(self): + valuesSelected = [] + for option in self.options: + if option.isChecked(): + valuesSelected.append(option.text()) + + return valuesSelected + + +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 + self.setStyleSheet(Stylesheets.MENUS_STYLESHEET) + + resolutionsEditDialog = QtWidgets.QGridLayout() + self.setLayout(resolutionsEditDialog) + scrollArea = QtWidgets.QScrollArea() + scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.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("Times", 13, QtGui.QFont.Bold)) + resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Raised | QtWidgets.QFrame.Panel) + + resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignCenter) + scrollLayout.addWidget(resolutionCategoryLabel) + scrollLayout.addWidget(resolutionCategoryWidget) + + confirmButton = QtWidgets.QPushButton('Confirm') + confirmButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + confirmButton.clicked.connect(self.accept) + resolutionsEditDialog.addWidget(confirmButton, 2, 1, 1, 1) + cancelButton = QtWidgets.QPushButton('Cancel') + cancelButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + cancelButton.clicked.connect(self.reject) + resolutionsEditDialog.addWidget(cancelButton, 2, 0, 1, 1) + + self.settingsTextboxes = [] + self.newSettings = {} + + for setting in self.settings: + settingTextbox = SettingsEditTextBox(self.settings.value(setting), setting) + self.settingsTextboxes.append(settingTextbox) + + if setting.startswith('Resolutions/'): + keyName = setting.split('Resolutions/', 1)[1] + 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 + self.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + + loggingSettingsLayout = QtWidgets.QGridLayout() + self.setLayout(loggingSettingsLayout) + scrollArea = QtWidgets.QScrollArea() + scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.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("Times", 13, QtGui.QFont.Bold)) + loggingCategoryLabel.setFrameStyle(QtWidgets.QFrame.Raised | QtWidgets.QFrame.Panel) + + loggingCategoryLabel.setAlignment(QtCore.Qt.AlignCenter) + scrollLayout.addWidget(loggingCategoryLabel) + scrollLayout.addWidget(loggingCategoryWidget) + + confirmButton = QtWidgets.QPushButton('Confirm') + confirmButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + confirmButton.clicked.connect(self.accept) + loggingSettingsLayout.addWidget(confirmButton, 2, 1, 1, 1) + cancelButton = QtWidgets.QPushButton('Cancel') + cancelButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + cancelButton.clicked.connect(self.reject) + loggingSettingsLayout.addWidget(cancelButton, 2, 0, 1, 1) + + self.settingsTextboxes = [] + self.newSettings = {} + + for setting in self.settings: + settingTextbox = SettingsEditTextBox(self.settings.value(setting), setting) + settingTextbox.setStyleSheet(Stylesheets.TEXT_BOX_STYLESHEET) + self.settingsTextboxes.append(settingTextbox) + if setting.startswith('Logging/'): + 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 + self.setStyleSheet(Stylesheets.MAIN_WINDOW_STYLESHEET) + + editDialogLayout = QtWidgets.QGridLayout() + self.setLayout(editDialogLayout) + scrollArea = QtWidgets.QScrollArea() + scrollArea.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn) + scrollArea.setHorizontalScrollBarPolicy(QtCore.Qt.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("Times", 13, QtGui.QFont.Bold)) + resolutionCategoryLabel.setFrameStyle(QtWidgets.QFrame.Raised | QtWidgets.QFrame.Panel) + + resolutionCategoryLabel.setAlignment(QtCore.Qt.AlignCenter) + scrollLayout.addWidget(resolutionCategoryLabel) + scrollLayout.addWidget(resolutionCategoryWidget) + + confirmButton = QtWidgets.QPushButton('Confirm') + confirmButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + confirmButton.clicked.connect(self.accept) + editDialogLayout.addWidget(confirmButton, 2, 1, 1, 1) + cancelButton = QtWidgets.QPushButton('Cancel') + cancelButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + cancelButton.clicked.connect(self.reject) + editDialogLayout.addWidget(cancelButton, 2, 0, 1, 1) + + self.settingsTextboxes = [] + self.settingsSingleChoice = [] + self.newSettings = {} + + for setting in self.settings: + if setting == 'Project/Resolution Result Grouping Threshold' or \ + setting == 'Project/Number of Answers Returned' or \ + setting == 'Project/Question Answering Retriever Value' or \ + setting == 'Project/Question Answering Reader Value': + keyName = setting.split('Project/', 1)[1] + settingTextbox = SettingsEditTextBox(self.settings.value(setting), setting) + settingTextbox.setStyleSheet(Stylesheets.TEXT_BOX_STYLESHEET) + self.settingsTextboxes.append(settingTextbox) + self.resolutionCategoryLayout.addRow(keyName, settingTextbox) + + elif setting == 'Project/Symlink or Copy Materials': + keyName = setting.split('Project/', 1)[1] + settingSingleChoice = SettingsEditSingleChoice(['Symlink', 'Copy'], self.settings.value(setting), + setting) + settingSingleChoice.setStyleSheet(Stylesheets.RADIO_BUTTON_STYLESHEET) + 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, settingsKey): + super(SettingsEditTextBox, self).__init__(str(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.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.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): + super(FindEntityOnCanvasDialog, self).__init__() + self.setModal(True) + self.setMinimumWidth(400) + self.setWindowTitle('Find Entity') + self.setStyleSheet(Stylesheets.MENUS_STYLESHEET) + + findLabel = QtWidgets.QLabel('Find:') + + findLabel.setAlignment(QtCore.Qt.AlignCenter) + self.findInput = QtWidgets.QLineEdit('') + self.findInput.setPlaceholderText('Type the primary field value to search for') + + confirmButton = QtWidgets.QPushButton('Confirm') + confirmButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + confirmButton.clicked.connect(self.accept) + cancelButton = QtWidgets.QPushButton('Cancel') + cancelButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + cancelButton.clicked.connect(self.reject) + + autoCompleter = QtWidgets.QCompleter(primaryFieldsList) + autoCompleter.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + 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) + findLayout.addWidget(cancelButton, 1, 0, 1, 1) + findLayout.addWidget(confirmButton, 1, 1, 1, 1) + + +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 = [] + self.setStyleSheet(Stylesheets.MERGE_STYLESHEET) + + 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') + acceptButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + cancelButton = QtWidgets.QPushButton('Cancel') + cancelButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET_2) + 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.setStyleSheet(Stylesheets.RADIO_BUTTON_STYLESHEET) + isPrimaryRadioButton.setText(entityJSON[list(entityJSON)[1]]) + + self.entitiesTable.setCellWidget(newRowIndex, 0, isPrimaryRadioButton) + + pixmapLabel = QtWidgets.QLabel() + entityPixmap = QtGui.QPixmap() + entityPixmap.loadFromData(entityJSON.get('Icon')) + pixmapLabel.setPixmap(entityPixmap) + + pixmapLabel.setAlignment(QtCore.Qt.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.AlignCenter) + self.entitiesTable.setCellWidget(newRowIndex, 2, incomingLinks) + outgoingLinks = QtWidgets.QLabel(str(len(self.parent.LENTDB.getOutgoingLinks(entityJSON['uid'])))) + + outgoingLinks.setAlignment(QtCore.Qt.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(0, 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 = [] + for widgetColumnIndex in range(5): + widgetsToShift.append(self.cellWidget(rowIndex, widgetColumnIndex)) + 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 = [] + for widgetColumnIndex in range(5): + widgetsToShift.append(self.cellWidget(rowIndex, widgetColumnIndex)) + 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('^') + upButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET) + + downButton = QtWidgets.QPushButton('v') + downButton.setStyleSheet(Stylesheets.BUTTON_STYLESHEET) + + 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 = [] + self.setStyleSheet(Stylesheets.MERGE_STYLESHEET) + + 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() + + count = 1 + for link in self.allLinks: + selectResolution = QtWidgets.QCheckBox(link['Resolution']) + selectResolution.setStyleSheet(Stylesheets.CHECK_BOX_STYLESHEET) + selectResolution.linkUID = link['uid'] + self.entitiesTable.setCellWidget(newRowIndex, count, selectResolution) + count += 1 + + 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) + self.splitEntities = [] + self.splitEntitiesWithLinks = [] + return + 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) + self.splitEntities = [] + self.splitEntitiesWithLinks = [] + return + elif entityName in self.splitEntities: + self.parent.MESSAGEHANDLER.info("Duplicate primary field value specified:\n" + entityName, + popUp=True) + self.splitEntities = [] + self.splitEntitiesWithLinks = [] + return + self.splitEntities.append(entityName) + + allLinkUIDsForEntity = [] + for columnIndex in range(1, self.entitiesTable.columnCount()): + if self.entitiesTable.cellWidget(newEntityRow, columnIndex).isChecked(): + allLinkUIDsForEntity.append(self.allLinks[columnIndex - 1]) + self.splitEntitiesWithLinks.append((entityName, allLinkUIDsForEntity)) + + # Clear out this list, no more need for it + self.splitEntities = [] + super(SplitEntitiesDialog, self).accept() + + +if __name__ == '__main__': + # Create a graphical application + application = QtWidgets.QApplication(sys.argv) + mainWindow = MainWindow() + sys.exit(application.exec()) diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..a9d132f --- /dev/null +++ b/build.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# Copy the Modules, Resources and Core directories, as well as Icon.ico, Nexus.py and requirements.txt to a new directory along with this file. +# Then, run the script. + +python3.9 -m venv buildEnv + +source buildEnv/bin/activate + +python3.9 -m pip install --upgrade -r requirements.txt + +python3.9 -m pip install --upgrade pyinstaller + +python3.9 -m PyInstaller --clean --icon="./Icon.ico" --noconsole --noconfirm --onedir --windowed --add-data "./Modules:Modules/" --add-data "./Resources:Resources/" --add-data "./Core:Core/" --collect-all "PySide6" --collect-all "networkx" --collect-all "pydot" --collect-all "msgpack" --hidden-import "_cffi_backend" --collect-all "folium" --collect-all "shodan" --collect-all "vtapi3" --collect-all "docker" --collect-all "exif" --collect-all "dns" --collect-all "pycountry" --collect-all "tldextract" --collect-all "requests_futures" --collect-all "branca" --collect-all "bs4" --hidden-import "pandas" --collect-all "docx2python" --collect-all "tweepy" --collect-all "PyPDF2" --collect-all "Wappalyzer" --collect-all "email_validator" --collect-all "seleniumwire" --add-data "./buildEnv/lib/python3.9/site-packages/social-analyzer:social-analyzer/" --hidden-import "PIL" --hidden-import "lz4" --hidden-import "lxml" --hidden-import "jellyfish" --hidden-import "logging" "./Nexus.py" + + +deactivate diff --git a/buildWin b/buildWin new file mode 100644 index 0000000..f75534e --- /dev/null +++ b/buildWin @@ -0,0 +1 @@ +python -m PyInstaller --clean --icon="Icon.ico" --noconsole --noconfirm --onedir --windowed --add-data "Modules;Modules" --add-data "Resources;Resources" --add-data "Core;Core" --collect-all "PySide6" --collect-all "networkx" --collect-all "pydot" --collect-all "msgpack" --hidden-import "_cffi_backend" --collect-all "folium" --collect-all "shodan" --collect-all "vtapi3" --collect-all "docker" --collect-all "exif" --collect-all "dns" --collect-all "pycountry" --collect-all "tldextract" --collect-all "requests_futures" --collect-all "branca" --collect-all "bs4" --hidden-import "pandas" --collect-all "docx2python" --collect-all "tweepy" --collect-all "PyPDF2" --collect-all "Wappalyzer" --collect-all "email_validator" --collect-all "seleniumwire" --add-data "C:\Users\IEUser\AppData\Roaming\Python\Python39\site-packages\social-analyzer;social-analyzer" --hidden-import "PIL" --hidden-import "lz4" --hidden-import "lxml" --hidden-import "jellyfish" --hidden-import "logging" --hidden-import "python-magic-bin" "./Nexus.py" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fe78e71 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,53 @@ +PySide6>=6.2.0 # https://lists.qt-project.org/pipermail/pyside/2021-September/003083.html + + +# For python-magic, the following packages may be required: +## Linux: +### sudo apt-get install libmagic1 +## Windows: +### pip install python-magic-bin +python-magic + +# The graphviz package is required. +## Linux: sudo apt-get install graphviz +## Windows: https://www.graphviz.org/download/ + +pydot +networkx + +defusedxml +cryptography +msgpack +folium +pillow +lz4 +urllib3 +reportlab +selenium +pandas +svglib + +##### Extra Packages for resolutions +requests +shodan +docx2python +PyPDF2 +tldextract +beautifulsoup4 +python-Wappalyzer +vtapi3 +social-analyzer +requests_futures +tweepy +selenium-wire +jellyfish +shodan +pandas +lxml +pycountry +ipwhois +docker +email-validator +docx2python +exif +xmltodict