1#!/usr/bin/env python3 2 3#****************************************************************************** 4# treelocalcontrol.py, provides a class for the main tree commands 5# 6# TreeLine, an information storage program 7# Copyright (C) 2020, Douglas W. Bell 8# 9# This is free software; you can redistribute it and/or modify it under the 10# terms of the GNU General Public License, either Version 2 or any later 11# version. This program is distributed in the hope that it will be useful, 12# but WITHOUT ANY WARRANTY. See the included LICENSE file for details. 13#****************************************************************************** 14 15import pathlib 16import json 17import os 18import sys 19import gzip 20import operator 21from itertools import chain 22from PyQt5.QtCore import QObject, QTimer, Qt, pyqtSignal 23from PyQt5.QtWidgets import (QAction, QActionGroup, QApplication, QDialog, 24 QFileDialog, QMenu, QMessageBox) 25import treemaincontrol 26import treestructure 27import treemodel 28import treeformats 29import treenode 30import treewindow 31import exports 32import miscdialogs 33import printdata 34import matheval 35import spellcheck 36import undo 37import p3 38import globalref 39 40 41class TreeLocalControl(QObject): 42 """Class to handle controls local to a model/view combination. 43 44 Provides methods for all local controls and stores a model & windows. 45 """ 46 controlActivated = pyqtSignal(QObject) 47 controlClosed = pyqtSignal(QObject) 48 def __init__(self, allActions, fileObj=None, treeStruct=None, 49 forceNewWindow=False, parent=None): 50 """Initialize the local tree controls. 51 52 Use an imported structure if given or open the file if path is given. 53 Always creates a new window. 54 Arguments: 55 allActions -- a dict containing the upper level actions 56 fileObj -- the path object or file object to open, if given 57 treeStruct -- an imported tree structure file, if given 58 forceNewWindow -- if True, use a new window regardless of option 59 parent -- a parent object if given 60 """ 61 super().__init__(parent) 62 self.printData = printdata.PrintData(self) 63 self.spellCheckLang = '' 64 self.allActions = allActions.copy() 65 self.setupActions() 66 self.filePathObj = (pathlib.Path(fileObj.name) if 67 hasattr(fileObj, 'read') else fileObj) 68 if treeStruct: 69 self.structure = treeStruct 70 elif fileObj: 71 if hasattr(fileObj, 'read'): 72 fileData = json.load(fileObj) 73 else: 74 with fileObj.open('r', encoding='utf-8') as f: 75 fileData = json.load(f) 76 self.structure = treestructure.TreeStructure(fileData) 77 self.printData.readData(fileData['properties']) 78 self.spellCheckLang = fileData['properties'].get('spellchk', '') 79 else: 80 self.structure = treestructure.TreeStructure(addDefaults=True) 81 fileInfoFormat = self.structure.treeFormats.fileInfoFormat 82 fileInfoFormat.updateFileInfo(self.filePathObj, 83 self.structure.fileInfoNode) 84 self.model = treemodel.TreeModel(self.structure) 85 self.model.treeModified.connect(self.updateRightViews) 86 87 self.modified = False 88 self.imported = False 89 self.compressed = False 90 self.encrypted = False 91 self.windowList = [] 92 self.activeWindow = None 93 self.findReplaceSpotRef = (None, 0) 94 QApplication.clipboard().dataChanged.connect(self.updateCommandsAvail) 95 self.structure.undoList = undo.UndoRedoList(self. 96 allActions['EditUndo'], 97 self) 98 self.structure.redoList = undo.UndoRedoList(self. 99 allActions['EditRedo'], 100 self) 101 self.structure.undoList.altListRef = self.structure.redoList 102 self.structure.redoList.altListRef = self.structure.undoList 103 self.autoSaveTimer = QTimer(self) 104 self.autoSaveTimer.timeout.connect(self.autoSave) 105 if not globalref.mainControl.activeControl: 106 self.windowNew(offset=0) 107 elif forceNewWindow or globalref.genOptions['OpenNewWindow']: 108 self.windowNew() 109 else: 110 oldControl = globalref.mainControl.activeControl 111 window = oldControl.activeWindow 112 if len(oldControl.windowList) > 1: 113 oldControl.windowList.remove(window) 114 else: 115 oldControl.controlClosed.emit(oldControl) 116 window.resetTreeModel(self.model) 117 self.setWindowSignals(window, True) 118 window.updateActions(self.allActions) 119 self.windowList.append(window) 120 self.updateWindowCaptions() 121 self.activeWindow = window 122 if fileObj and self.structure.childRefErrorNodes: 123 msg = _('Warning - file corruption!\n' 124 'Skipped bad child references in the following nodes:') 125 for node in self.structure.childRefErrorNodes: 126 msg += '\n "{}"'.format(node.title()) 127 QMessageBox.warning(self.activeWindow, 'TreeLine', msg) 128 self.structure.childRefErrorNodes = [] 129 130 def setWindowSignals(self, window, removeOld=False): 131 """Setup signals between the window and this controller. 132 133 Arguments: 134 window -- the window to link 135 removeOld -- if True, remove old signals 136 """ 137 if removeOld: 138 window.selectChanged.disconnect() 139 window.nodeModified.disconnect() 140 window.treeModified.disconnect() 141 window.winActivated.disconnect() 142 window.winClosing.disconnect() 143 window.selectChanged.connect(self.updateCommandsAvail) 144 window.nodeModified.connect(self.updateTreeNode) 145 window.treeModified.connect(self.updateTree) 146 window.winActivated.connect(self.setActiveWin) 147 window.winClosing.connect(self.checkWindowClose) 148 window.setExternalSignals() 149 150 def updateTreeNode(self, node, setModified=True): 151 """Update the full tree in all windows. 152 153 Also update right views in secondary windows. 154 Arguments: 155 node -- the node to be updated 156 setModified -- if True, set the modified flag for this file 157 """ 158 if node.setConditionalType(self.structure): 159 self.activeWindow.updateRightViews(outputOnly=True) 160 if (self.structure.treeFormats.mathFieldRefDict and 161 node.updateNodeMathFields(self.structure.treeFormats)): 162 self.activeWindow.updateRightViews(outputOnly=True) 163 if globalref.genOptions['ShowMath']: 164 self.activeWindow.refreshDataEditViews() 165 for window in self.windowList: 166 window.updateTreeNode(node) 167 if window.treeFilterView: 168 window.treeFilterView.updateItem(node) 169 if setModified: 170 self.setModified() 171 172 def updateTree(self, setModified=True): 173 """Update the full tree in all windows. 174 175 Also update right views in secondary windows. 176 Arguments: 177 setModified -- if True, set the modified flag for this file 178 """ 179 QApplication.setOverrideCursor(Qt.WaitCursor) 180 typeChanges = 0 181 if self.structure.treeFormats.conditionalTypes: 182 for node in self.structure.childList: 183 typeChanges += node.setDescendantConditionalTypes(self. 184 structure) 185 self.updateAllMathFields() 186 for window in self.windowList: 187 window.updateTree() 188 if window != self.activeWindow or typeChanges: 189 window.updateRightViews() 190 if window.treeFilterView: 191 window.treeFilterView.updateContents() 192 if setModified: 193 self.setModified() 194 QApplication.restoreOverrideCursor() 195 196 def updateRightViews(self, setModified=False, otherTrees=False): 197 """Update the right-hand views in all windows. 198 199 Arguments: 200 setModified -- if True, set the modified flag for this file 201 otherTrees -- if True, also update trees in non-active windows 202 """ 203 for window in self.windowList: 204 window.updateRightViews() 205 if otherTrees and window != self.activeWindow: 206 window.updateTree() 207 if setModified: 208 self.setModified() 209 210 def updateAll(self, setModified=True): 211 """Update the full tree and right-hand views in all windows. 212 213 Arguments: 214 setModified -- if True, set the modified flag for this file 215 """ 216 QApplication.setOverrideCursor(Qt.WaitCursor) 217 if self.structure.treeFormats.conditionalTypes: 218 for node in self.structure.childList: 219 node.setDescendantConditionalTypes(self.structure) 220 self.updateAllMathFields() 221 for window in self.windowList: 222 window.updateTree() 223 if window.treeFilterView: 224 window.treeFilterView.updateContents() 225 window.updateRightViews() 226 self.updateCommandsAvail() 227 if setModified: 228 self.setModified() 229 # self.structure.debugCheck() 230 QApplication.restoreOverrideCursor() 231 232 def updateAllMathFields(self): 233 """Recalculate all math fields in the entire tree. 234 """ 235 for eqnRefDict in self.structure.treeFormats.mathLevelList: 236 if list(eqnRefDict.values())[0][0].evalDirection != (matheval. 237 EvalDir. 238 upward): 239 for node in self.structure.descendantGen(): 240 for eqnRef in eqnRefDict.get(node.formatRef.name, []): 241 node.data[eqnRef.eqnField.name] = (eqnRef.eqnField. 242 equationValue(node)) 243 else: 244 spot = self.structure.structSpot().lastDescendantSpot() 245 while spot: 246 node = spot.nodeRef 247 for eqnRef in eqnRefDict.get(node.formatRef.name, []): 248 node.data[eqnRef.eqnField.name] = (eqnRef.eqnField. 249 equationValue(node)) 250 spot = spot.prevTreeSpot() 251 252 def updateCommandsAvail(self): 253 """Set commands available based on node selections. 254 """ 255 selSpots = self.currentSelectionModel().selectedSpots() 256 hasSelect = len(selSpots) > 0 257 rootSpots = [spot for spot in selSpots if not 258 spot.parentSpot.parentSpot] 259 hasPrevSibling = (len(selSpots) and None not in 260 [spot.prevSiblingSpot() for spot in selSpots]) 261 hasNextSibling = (len(selSpots) and None not in 262 [spot.nextSiblingSpot() for spot in selSpots]) 263 hasChildren = (sum([len(spot.nodeRef.childList) for spot in selSpots]) 264 > 0) 265 mime = QApplication.clipboard().mimeData() 266 hasData = len(mime.data('application/json')) > 0 267 hasText = len(mime.data('text/plain')) > 0 268 self.allActions['EditPaste'].setEnabled(hasData or hasText) 269 self.allActions['EditPasteChild'].setEnabled(hasData) 270 self.allActions['EditPasteBefore'].setEnabled(hasData and hasSelect) 271 self.allActions['EditPasteAfter'].setEnabled(hasData and hasSelect) 272 self.allActions['EditPasteCloneChild'].setEnabled(hasData) 273 self.allActions['EditPasteCloneBefore'].setEnabled(hasData and 274 hasSelect) 275 self.allActions['EditPasteCloneAfter'].setEnabled(hasData and 276 hasSelect) 277 self.allActions['NodeRename'].setEnabled(len(selSpots) == 1) 278 self.allActions['NodeInsertBefore'].setEnabled(hasSelect) 279 self.allActions['NodeInsertAfter'].setEnabled(hasSelect) 280 self.allActions['NodeDelete'].setEnabled(hasSelect and len(rootSpots) < 281 len(self.structure.childList)) 282 self.allActions['NodeIndent'].setEnabled(hasPrevSibling) 283 self.allActions['NodeUnindent'].setEnabled(hasSelect and 284 len(rootSpots) == 0) 285 self.allActions['NodeMoveUp'].setEnabled(hasPrevSibling) 286 self.allActions['NodeMoveDown'].setEnabled(hasNextSibling) 287 self.allActions['NodeMoveFirst'].setEnabled(hasPrevSibling) 288 self.allActions['NodeMoveLast'].setEnabled(hasNextSibling) 289 self.allActions['DataNodeType'].parent().setEnabled(hasSelect) 290 self.allActions['DataFlatCategory'].setEnabled(hasChildren) 291 self.allActions['DataAddCategory'].setEnabled(hasChildren) 292 self.allActions['DataSwapCategory'].setEnabled(hasChildren) 293 if self.activeWindow.treeFilterView: 294 self.allActions['NodeInsertBefore'].setEnabled(False) 295 self.allActions['NodeInsertAfter'].setEnabled(False) 296 self.allActions['NodeAddChild'].setEnabled(False) 297 self.allActions['NodeIndent'].setEnabled(False) 298 self.allActions['NodeUnindent'].setEnabled(False) 299 self.allActions['NodeMoveUp'].setEnabled(False) 300 self.allActions['NodeMoveDown'].setEnabled(False) 301 self.allActions['NodeMoveFirst'].setEnabled(False) 302 self.allActions['NodeMoveLast'].setEnabled(False) 303 else: 304 self.allActions['NodeAddChild'].setEnabled(True) 305 self.activeWindow.updateCommandsAvail() 306 307 def updateWindowCaptions(self): 308 """Update the caption for all windows. 309 """ 310 for window in self.windowList: 311 window.setCaption(self.filePathObj, self.modified) 312 313 def setModified(self, modified=True): 314 """Set the modified flag on this file and update commands available. 315 316 Arguments: 317 modified -- the modified state to set 318 """ 319 if modified != self.modified: 320 self.modified = modified 321 self.allActions['FileSave'].setEnabled(modified) 322 self.updateWindowCaptions() 323 self.resetAutoSave() 324 325 def expandRootNodes(self, maxNum=5): 326 """Expand root node if there are fewer than the maximum. 327 328 Arguments: 329 maxNum -- only expand if there are fewer root nodes than this. 330 """ 331 if len(self.structure.childList) < maxNum: 332 treeView = self.activeWindow.treeView 333 for spot in self.structure.rootSpots(): 334 treeView.expandSpot(spot) 335 336 def selectRootSpot(self): 337 """Select the first root spot in the tree. 338 339 Does not signal an update. 340 """ 341 self.currentSelectionModel().selectSpots([self.structure. 342 rootSpots()[0]], False) 343 344 def currentSelectionModel(self): 345 """Return the current tree's selection model. 346 """ 347 return self.activeWindow.treeView.selectionModel() 348 349 def setActiveWin(self, window): 350 """When a window is activated, stores it and emits a signal. 351 352 Arguments: 353 window -- the new active window 354 """ 355 self.activeWindow = window 356 self.controlActivated.emit(self) 357 self.updateCommandsAvail() 358 359 def checkWindowClose(self, window): 360 """Check for modified files and delete ref when a window is closing. 361 362 Arguments: 363 window -- the window being closed 364 """ 365 if len(self.windowList) > 1: 366 self.windowList.remove(window) 367 window.allowCloseFlag = True 368 # # keep ref until Qt window can fully close 369 # self.oldWindow = window 370 elif self.checkSaveChanges(): 371 window.allowCloseFlag = True 372 self.controlClosed.emit(self) 373 else: 374 window.allowCloseFlag = False 375 376 def checkSaveChanges(self): 377 """Ask for save if doc modified, return True if OK to continue. 378 379 Save this doc if directed. 380 Return True if not modified, if saved or if discarded. 381 Return False on cancel. 382 """ 383 if not self.modified or len(self.windowList) > 1: 384 return True 385 promptText = (_('Save changes to {}?').format(self.filePathObj) 386 if self.filePathObj else _('Save changes?')) 387 ans = QMessageBox.information(self.activeWindow, 'TreeLine', 388 promptText, 389 QMessageBox.Save | QMessageBox.Discard | 390 QMessageBox.Cancel, QMessageBox.Save) 391 if ans == QMessageBox.Save: 392 self.fileSave() 393 elif ans == QMessageBox.Cancel: 394 return False 395 else: 396 self.deleteAutoSaveFile() 397 return True 398 399 def closeWindows(self): 400 """Close this control's windows prior to quiting the application. 401 """ 402 for window in self.windowList: 403 window.close() 404 405 def autoSave(self): 406 """Save a backup file if appropriate. 407 408 Called from the timer. 409 """ 410 if self.filePathObj and not self.imported: 411 self.fileSave(True) 412 413 def resetAutoSave(self): 414 """Start or stop the auto-save timer based on file modified status. 415 416 Also delete old autosave files if file becomes unmodified. 417 """ 418 self.autoSaveTimer.stop() 419 minutes = globalref.genOptions['AutoSaveMinutes'] 420 if minutes and self.modified: 421 self.autoSaveTimer.start(60000 * minutes) 422 else: 423 self.deleteAutoSaveFile() 424 425 def deleteAutoSaveFile(self): 426 """Delete an auto save file if it exists. 427 """ 428 filePath = pathlib.Path(str(self.filePathObj) + '~') 429 if self.filePathObj and filePath.is_file(): 430 try: 431 filePath.unlink() 432 except OSError: 433 QMessageBox.warning(self.activeWindow, 'TreeLine', 434 _('Error - could not delete backup file {}'). 435 format(filePath)) 436 437 def windowActions(self, startNum=1, active=False): 438 """Return a list of window menu actions to select this file's windows. 439 440 Arguments: 441 startNum -- where to start numbering the action names 442 active -- if True, activate the current active window 443 """ 444 actions = [] 445 maxActionPathLength = 30 446 abbrevPath = str(self.filePathObj) 447 if len(abbrevPath) > maxActionPathLength: 448 truncLength = maxActionPathLength - 3 449 pos = abbrevPath.find(os.sep, len(abbrevPath) - truncLength) 450 if pos < 0: 451 pos = len(abbrevPath) - truncLength 452 abbrevPath = '...' + abbrevPath[pos:] 453 for window in self.windowList: 454 action = QAction('&{0:d} {1}'.format(startNum, abbrevPath), self, 455 statusTip=str(self.filePathObj), checkable=True) 456 action.triggered.connect(window.activateAndRaise) 457 if active and window == self.activeWindow: 458 action.setChecked(True) 459 actions.append(action) 460 startNum += 1 461 return actions 462 463 def setupActions(self): 464 """Add the actions for contols at the local level. 465 466 These actions affect an individual file, possibly in multiple windows. 467 """ 468 localActions = {} 469 470 fileSaveAct = QAction(_('&Save'), self, toolTip=_('Save File'), 471 statusTip=_('Save the current file')) 472 fileSaveAct.setEnabled(False) 473 fileSaveAct.triggered.connect(self.fileSave) 474 localActions['FileSave'] = fileSaveAct 475 476 fileSaveAsAct = QAction(_('Save &As...'), self, 477 statusTip=_('Save the file with a new name')) 478 fileSaveAsAct.triggered.connect(self.fileSaveAs) 479 localActions['FileSaveAs'] = fileSaveAsAct 480 481 fileExportAct = QAction(_('&Export...'), self, 482 statusTip=_('Export the file in various other formats')) 483 fileExportAct.triggered.connect(self.fileExport) 484 localActions['FileExport'] = fileExportAct 485 486 filePropertiesAct = QAction(_('Prop&erties...'), self, 487 statusTip=_('Set file parameters like compression and encryption')) 488 filePropertiesAct.triggered.connect(self.fileProperties) 489 localActions['FileProperties'] = filePropertiesAct 490 491 filePrintSetupAct = QAction(_('P&rint Setup...'), self, 492 statusTip=_('Set margins, page size and other printing options')) 493 filePrintSetupAct.triggered.connect(self.printData.printSetup) 494 localActions['FilePrintSetup'] = filePrintSetupAct 495 496 filePrintPreviewAct = QAction(_('Print Pre&view...'), self, 497 statusTip=_('Show a preview of printing results')) 498 filePrintPreviewAct.triggered.connect(self.printData.printPreview) 499 localActions['FilePrintPreview'] = filePrintPreviewAct 500 501 filePrintAct = QAction(_('&Print...'), self, 502 statusTip=_('Print tree output based on current options')) 503 filePrintAct.triggered.connect(self.printData.filePrint) 504 localActions['FilePrint'] = filePrintAct 505 506 filePrintPdfAct = QAction(_('Print &to PDF...'), self, 507 statusTip=_('Export to PDF with current printing options')) 508 filePrintPdfAct.triggered.connect(self.printData.filePrintPdf) 509 localActions['FilePrintPdf'] = filePrintPdfAct 510 511 editUndoAct = QAction(_('&Undo'), self, 512 statusTip=_('Undo the previous action')) 513 editUndoAct.triggered.connect(self.editUndo) 514 localActions['EditUndo'] = editUndoAct 515 516 editRedoAct = QAction(_('&Redo'), self, 517 statusTip=_('Redo the previous undo')) 518 editRedoAct.triggered.connect(self.editRedo) 519 localActions['EditRedo'] = editRedoAct 520 521 editCutAct = QAction(_('Cu&t'), self, 522 statusTip=_('Cut the branch or text to the clipboard')) 523 editCutAct.triggered.connect(self.editCut) 524 localActions['EditCut'] = editCutAct 525 526 editCopyAct = QAction(_('&Copy'), self, 527 statusTip=_('Copy the branch or text to the clipboard')) 528 editCopyAct.triggered.connect(self.editCopy) 529 localActions['EditCopy'] = editCopyAct 530 531 editPasteAct = QAction(_('&Paste'), self, 532 statusTip=_('Paste nodes or text from the clipboard')) 533 editPasteAct.triggered.connect(self.editPaste) 534 localActions['EditPaste'] = editPasteAct 535 536 editPastePlainAct = QAction(_('Pa&ste Plain Text'), self, 537 statusTip=_('Paste non-formatted text from the clipboard')) 538 editPastePlainAct.setEnabled(False) 539 localActions['EditPastePlain'] = editPastePlainAct 540 541 editPasteChildAct = QAction(_('Paste C&hild'), self, 542 statusTip=_('Paste a child node from the clipboard')) 543 editPasteChildAct.triggered.connect(self.editPasteChild) 544 localActions['EditPasteChild'] = editPasteChildAct 545 546 editPasteBeforeAct = QAction(_('Paste Sibling &Before'), self, 547 statusTip=_('Paste a sibling before selection')) 548 editPasteBeforeAct.triggered.connect(self.editPasteBefore) 549 localActions['EditPasteBefore'] = editPasteBeforeAct 550 551 editPasteAfterAct = QAction(_('Paste Sibling &After'), self, 552 statusTip=_('Paste a sibling after selection')) 553 editPasteAfterAct.triggered.connect(self.editPasteAfter) 554 localActions['EditPasteAfter'] = editPasteAfterAct 555 556 editPasteCloneChildAct = QAction(_('Paste Cl&oned Child'), self, 557 statusTip=_('Paste a child clone from the clipboard')) 558 editPasteCloneChildAct.triggered.connect(self.editPasteCloneChild) 559 localActions['EditPasteCloneChild'] = editPasteCloneChildAct 560 561 editPasteCloneBeforeAct = QAction(_('Paste Clo&ned Sibling Before'), 562 self, statusTip=_('Paste a sibling clone before selection')) 563 editPasteCloneBeforeAct.triggered.connect(self.editPasteCloneBefore) 564 localActions['EditPasteCloneBefore'] = editPasteCloneBeforeAct 565 566 editPasteCloneAfterAct = QAction(_('Paste Clone&d Sibling After'), 567 self, statusTip=_('Paste a sibling clone after selection')) 568 editPasteCloneAfterAct.triggered.connect(self.editPasteCloneAfter) 569 localActions['EditPasteCloneAfter'] = editPasteCloneAfterAct 570 571 nodeRenameAct = QAction(_('&Rename'), self, 572 statusTip=_('Rename the current tree entry title')) 573 nodeRenameAct.triggered.connect(self.nodeRename) 574 localActions['NodeRename'] = nodeRenameAct 575 576 nodeAddChildAct = QAction(_('Add &Child'), self, 577 statusTip=_('Add new child to selected parent')) 578 nodeAddChildAct.triggered.connect(self.nodeAddChild) 579 localActions['NodeAddChild'] = nodeAddChildAct 580 581 nodeInBeforeAct = QAction(_('Insert Sibling &Before'), self, 582 statusTip=_('Insert new sibling before selection')) 583 nodeInBeforeAct.triggered.connect(self.nodeInBefore) 584 localActions['NodeInsertBefore'] = nodeInBeforeAct 585 586 nodeInAfterAct = QAction(_('Insert Sibling &After'), self, 587 statusTip=_('Insert new sibling after selection')) 588 nodeInAfterAct.triggered.connect(self.nodeInAfter) 589 localActions['NodeInsertAfter'] = nodeInAfterAct 590 591 nodeDeleteAct = QAction(_('&Delete Node'), self, 592 statusTip=_('Delete the selected nodes')) 593 nodeDeleteAct.triggered.connect(self.nodeDelete) 594 localActions['NodeDelete'] = nodeDeleteAct 595 596 nodeIndentAct = QAction(_('&Indent Node'), self, 597 statusTip=_('Indent the selected nodes')) 598 nodeIndentAct.triggered.connect(self.nodeIndent) 599 localActions['NodeIndent'] = nodeIndentAct 600 601 nodeUnindentAct = QAction(_('&Unindent Node'), self, 602 statusTip=_('Unindent the selected nodes')) 603 nodeUnindentAct.triggered.connect(self.nodeUnindent) 604 localActions['NodeUnindent'] = nodeUnindentAct 605 606 nodeMoveUpAct = QAction(_('&Move Up'), self, 607 statusTip=_('Move the selected nodes up')) 608 nodeMoveUpAct.triggered.connect(self.nodeMoveUp) 609 localActions['NodeMoveUp'] = nodeMoveUpAct 610 611 nodeMoveDownAct = QAction(_('M&ove Down'), self, 612 statusTip=_('Move the selected nodes down')) 613 nodeMoveDownAct.triggered.connect(self.nodeMoveDown) 614 localActions['NodeMoveDown'] = nodeMoveDownAct 615 616 nodeMoveFirstAct = QAction(_('Move &First'), self, 617 statusTip=_('Move the selected nodes to be the first children')) 618 nodeMoveFirstAct.triggered.connect(self.nodeMoveFirst) 619 localActions['NodeMoveFirst'] = nodeMoveFirstAct 620 621 nodeMoveLastAct = QAction(_('Move &Last'), self, 622 statusTip=_('Move the selected nodes to be the last children')) 623 nodeMoveLastAct.triggered.connect(self.nodeMoveLast) 624 localActions['NodeMoveLast'] = nodeMoveLastAct 625 626 title = _('&Set Node Type') 627 key = globalref.keyboardOptions['DataNodeType'] 628 if not key.isEmpty(): 629 title = '{0} ({1})'.format(title, key.toString()) 630 self.typeSubMenu = QMenu(title, 631 statusTip=_('Set the node type for selected nodes')) 632 self.typeSubMenu.aboutToShow.connect(self.loadTypeSubMenu) 633 self.typeSubMenu.triggered.connect(self.dataSetType) 634 typeContextMenuAct = QAction(_('Set Node Type'), self.typeSubMenu) 635 typeContextMenuAct.triggered.connect(self.showTypeContextMenu) 636 localActions['DataNodeType'] = typeContextMenuAct 637 638 dataCopyTypeAct = QAction(_('Copy Types from &File...'), self, 639 statusTip=_('Copy the configuration from another TreeLine file')) 640 dataCopyTypeAct.triggered.connect(self.dataCopyType) 641 localActions['DataCopyType'] = dataCopyTypeAct 642 643 dataRegenRefsAct = QAction(_('&Regenerate References'), self, 644 statusTip=_('Force update of all conditional types & math fields')) 645 dataRegenRefsAct.triggered.connect(self.dataRegenRefs) 646 localActions['DataRegenRefs'] = dataRegenRefsAct 647 648 dataCloneMatchesAct = QAction(_('Clone All &Matched Nodes'), self, 649 statusTip=_('Convert all matching nodes into clones')) 650 dataCloneMatchesAct.triggered.connect(self.dataCloneMatches) 651 localActions['DataCloneMatches'] = dataCloneMatchesAct 652 653 dataDetachClonesAct = QAction(_('&Detach Clones'), self, 654 statusTip=_('Detach all cloned nodes in current branches')) 655 dataDetachClonesAct.triggered.connect(self.dataDetachClones) 656 localActions['DataDetachClones'] = dataDetachClonesAct 657 658 dataFlatCatAct = QAction(_('Flatten &by Category'), self, 659 statusTip=_('Collapse descendants by merging fields')) 660 dataFlatCatAct.triggered.connect(self.dataFlatCategory) 661 localActions['DataFlatCategory'] = dataFlatCatAct 662 663 dataAddCatAct = QAction(_('Add Category &Level...'), self, 664 statusTip=_('Insert category nodes above children')) 665 dataAddCatAct.triggered.connect(self.dataAddCategory) 666 localActions['DataAddCategory'] = dataAddCatAct 667 668 dataSwapCatAct = QAction(_('S&wap Category Levels'), self, 669 statusTip=_('Swap child and grandchild category nodes')) 670 dataSwapCatAct.triggered.connect(self.dataSwapCategory) 671 localActions['DataSwapCategory'] = dataSwapCatAct 672 673 toolsSpellCheckAct = QAction(_('&Spell Check...'), self, 674 statusTip=_('Spell check the tree\'s text data')) 675 toolsSpellCheckAct.triggered.connect(self.toolsSpellCheck) 676 localActions['ToolsSpellCheck'] = toolsSpellCheckAct 677 678 formatBoldAct = QAction(_('&Bold Font'), self, 679 statusTip=_('Set the current or selected font to bold'), 680 checkable=True) 681 formatBoldAct.setEnabled(False) 682 localActions['FormatBoldFont'] = formatBoldAct 683 684 formatItalicAct = QAction(_('&Italic Font'), self, 685 statusTip=_('Set the current or selected font to italic'), 686 checkable=True) 687 formatItalicAct.setEnabled(False) 688 localActions['FormatItalicFont'] = formatItalicAct 689 690 formatUnderlineAct = QAction(_('U&nderline Font'), self, 691 statusTip=_('Set the current or selected font to underline'), 692 checkable=True) 693 formatUnderlineAct.setEnabled(False) 694 localActions['FormatUnderlineFont'] = formatUnderlineAct 695 696 title = _('&Font Size') 697 key = globalref.keyboardOptions['FormatFontSize'] 698 if not key.isEmpty(): 699 title = '{0} ({1})'.format(title, key.toString()) 700 self.fontSizeSubMenu = QMenu(title, 701 statusTip=_('Set size of the current or selected text')) 702 sizeActions = QActionGroup(self) 703 for size in (_('Small'), _('Default'), _('Large'), _('Larger'), 704 _('Largest')): 705 action = QAction(size, sizeActions) 706 action.setCheckable(True) 707 self.fontSizeSubMenu.addActions(sizeActions.actions()) 708 self.fontSizeSubMenu.setEnabled(False) 709 fontSizeContextMenuAct = QAction(_('Set Font Size'), 710 self.fontSizeSubMenu) 711 localActions['FormatFontSize'] = fontSizeContextMenuAct 712 713 formatColorAct = QAction(_('Font C&olor...'), self, 714 statusTip=_('Set the color of the current or selected text')) 715 formatColorAct.setEnabled(False) 716 localActions['FormatFontColor'] = formatColorAct 717 718 formatExtLinkAct = QAction(_('&External Link...'), self, 719 statusTip=_('Add or modify an extrnal web link')) 720 formatExtLinkAct.setEnabled(False) 721 localActions['FormatExtLink'] = formatExtLinkAct 722 723 formatIntLinkAct = QAction(_('Internal &Link...'), self, 724 statusTip=_('Add or modify an internal node link')) 725 formatIntLinkAct.setEnabled(False) 726 localActions['FormatIntLink'] = formatIntLinkAct 727 728 formatInsDateAct = QAction(_('Insert &Date'), self, 729 statusTip=_('Insert current date as text')) 730 formatInsDateAct.setEnabled(False) 731 localActions['FormatInsertDate'] = formatInsDateAct 732 733 formatClearFormatAct = QAction(_('Clear For&matting'), self, 734 statusTip=_('Clear current or selected text formatting')) 735 formatClearFormatAct.setEnabled(False) 736 localActions['FormatClearFormat'] = formatClearFormatAct 737 738 winNewAct = QAction(_('&New Window'), self, 739 statusTip=_('Open a new window for the same file')) 740 winNewAct.triggered.connect(self.windowNew) 741 localActions['WinNewWindow'] = winNewAct 742 743 for name, action in localActions.items(): 744 icon = globalref.toolIcons.getIcon(name.lower()) 745 if icon: 746 action.setIcon(icon) 747 key = globalref.keyboardOptions[name] 748 if not key.isEmpty(): 749 action.setShortcut(key) 750 typeIcon = globalref.toolIcons.getIcon('DataNodeType'.lower()) 751 if typeIcon: 752 self.typeSubMenu.setIcon(typeIcon) 753 fontIcon = globalref.toolIcons.getIcon('FormatFontSize'.lower()) 754 if fontIcon: 755 self.fontSizeSubMenu.setIcon(fontIcon) 756 self.allActions.update(localActions) 757 758 def fileSave(self, backupFile=False): 759 """Save the currently active file. 760 761 Arguments: 762 backupFile -- if True, write auto-save backup file instead 763 """ 764 if not self.filePathObj or self.imported: 765 self.fileSaveAs() 766 return 767 QApplication.setOverrideCursor(Qt.WaitCursor) 768 savePathObj = self.filePathObj 769 if backupFile: 770 savePathObj = pathlib.Path(str(savePathObj) + '~') 771 else: 772 self.structure.purgeOldFieldData() 773 fileData = self.structure.fileData() 774 fileData['properties'].update(self.printData.fileData()) 775 if self.spellCheckLang: 776 fileData['properties']['spellchk'] = self.spellCheckLang 777 if not self.compressed and not self.encrypted: 778 indent = 3 if globalref.genOptions['PrettyPrint'] else 0 779 try: 780 with savePathObj.open('w', encoding='utf-8', 781 newline='\n') as f: 782 json.dump(fileData, f, indent=indent, sort_keys=True) 783 except IOError: 784 QApplication.restoreOverrideCursor() 785 QMessageBox.warning(self.activeWindow, 'TreeLine', 786 _('Error - could not write to {}'). 787 format(savePathObj)) 788 return 789 else: 790 data = json.dumps(fileData, indent=0, sort_keys=True).encode() 791 if self.compressed: 792 data = gzip.compress(data) 793 if self.encrypted: 794 password = (globalref.mainControl.passwords. 795 get(self.filePathObj, '')) 796 if not password: 797 QApplication.restoreOverrideCursor() 798 dialog = miscdialogs.PasswordDialog(True, '', 799 self.activeWindow) 800 if dialog.exec_() != QDialog.Accepted: 801 return 802 QApplication.setOverrideCursor(Qt.WaitCursor) 803 password = dialog.password 804 if miscdialogs.PasswordDialog.remember: 805 globalref.mainControl.passwords[self. 806 filePathObj] = password 807 data = (treemaincontrol.encryptPrefix + 808 p3.p3_encrypt(data, password.encode())) 809 try: 810 with savePathObj.open('wb') as f: 811 f.write(data) 812 except IOError: 813 QApplication.restoreOverrideCursor() 814 QMessageBox.warning(self.activeWindow, 'TreeLine', 815 _('Error - could not write to {}'). 816 format(savePathObj)) 817 return 818 QApplication.restoreOverrideCursor() 819 if not backupFile: 820 fileInfoFormat = self.structure.treeFormats.fileInfoFormat 821 fileInfoFormat.updateFileInfo(self.filePathObj, 822 self.structure.fileInfoNode) 823 self.setModified(False) 824 self.imported = False 825 self.activeWindow.statusBar().showMessage(_('File saved'), 3000) 826 827 def fileSaveAs(self): 828 """Prompt for a new file name and save the file. 829 """ 830 oldPathObj = self.filePathObj 831 oldModifiedFlag = self.modified 832 oldImportFlag = self.imported 833 self.modified = True 834 self.imported = False 835 filters = ';;'.join((globalref.fileFilters['trlnsave'], 836 globalref.fileFilters['trlngz'], 837 globalref.fileFilters['trlnenc'])) 838 initFilter = globalref.fileFilters['trlnsave'] 839 defaultPathObj = globalref.mainControl.defaultPathObj() 840 if defaultPathObj.is_file(): 841 defaultPathObj = defaultPathObj.with_suffix('.trln') 842 newPath, selectFilter = (QFileDialog. 843 getSaveFileName(self.activeWindow, 844 _('TreeLine - Save As'), 845 str(defaultPathObj), 846 filters, initFilter)) 847 if newPath: 848 self.filePathObj = pathlib.Path(newPath) 849 if not self.filePathObj.suffix: 850 self.filePathObj = self.filePathObj.with_suffix('.trln') 851 if selectFilter != initFilter: 852 self.compressed = (selectFilter == 853 globalref.fileFilters['trlngz']) 854 self.encrypted = (selectFilter == 855 globalref.fileFilters['trlnenc']) 856 self.fileSave() 857 if not self.modified: 858 globalref.mainControl.recentFiles.addItem(self.filePathObj) 859 self.updateWindowCaptions() 860 return 861 self.filePathObj = oldPathObj 862 self.modified = oldModifiedFlag 863 self.imported = oldImportFlag 864 865 def fileExport(self): 866 """Export the file in various other formats. 867 """ 868 exportControl = exports.ExportControl(self.structure, 869 self.currentSelectionModel(), 870 globalref.mainControl. 871 defaultPathObj(), self.printData) 872 try: 873 exportControl.interactiveExport() 874 except IOError: 875 QApplication.restoreOverrideCursor() 876 QMessageBox.warning(self.activeWindow, 'TreeLine', 877 _('Error - could not write to file')) 878 879 def fileProperties(self): 880 """Show dialog to set file parameters like compression and encryption. 881 """ 882 origZeroBlanks = self.structure.mathZeroBlanks 883 dialog = miscdialogs.FilePropertiesDialog(self, self.activeWindow) 884 if dialog.exec_() == QDialog.Accepted: 885 self.setModified() 886 if self.structure.mathZeroBlanks != origZeroBlanks: 887 self.updateAll(False) 888 889 def editUndo(self): 890 """Undo the previous action and update the views. 891 """ 892 self.structure.undoList.undo() 893 self.updateAll(False) 894 895 def editRedo(self): 896 """Redo the previous undo and update the views. 897 """ 898 self.structure.redoList.undo() 899 self.updateAll(False) 900 901 def editCut(self): 902 """Cut the branch or text to the clipboard. 903 """ 904 widget = QApplication.focusWidget() 905 try: 906 if widget.hasSelectedText(): 907 widget.cut() 908 return 909 except AttributeError: 910 pass 911 self.currentSelectionModel().copySelectedNodes() 912 selSpots = self.currentSelectionModel().selectedSpots() 913 rootSpots = [spot for spot in selSpots if not 914 spot.parentSpot.parentSpot] 915 if selSpots and len(rootSpots) < len(self.structure.childList): 916 self.nodeDelete() 917 918 def editCopy(self): 919 """Copy the branch or text to the clipboard. 920 921 Copy from any selection in non-focused output view, or copy from 922 any focused editor, or copy from tree. 923 """ 924 widgets = [QApplication.focusWidget()] 925 splitter = self.activeWindow.rightTabs.currentWidget() 926 if splitter == self.activeWindow.outputSplitter: 927 widgets[0:0] = [splitter.widget(0), splitter.widget(1)] 928 for widget in widgets: 929 try: 930 if widget.hasSelectedText(): 931 widget.copy() 932 return 933 except AttributeError: 934 pass 935 self.currentSelectionModel().copySelectedNodes() 936 937 def editPaste(self): 938 """Paste nodes or text from the clipboard. 939 """ 940 if self.activeWindow.treeView.hasFocus(): 941 self.editPasteChild() 942 else: 943 widget = QApplication.focusWidget() 944 try: 945 widget.paste() 946 except AttributeError: 947 pass 948 949 def editPasteChild(self): 950 """Paste a child node from the clipboard. 951 """ 952 if (self.currentSelectionModel().selectedSpots(). 953 pasteChild(self.structure, self.activeWindow.treeView)): 954 self.updateAll() 955 globalref.mainControl.updateConfigDialog() 956 957 def editPasteBefore(self): 958 """Paste a sibling before selection. 959 """ 960 treeView = self.activeWindow.treeView 961 selSpots = self.currentSelectionModel().selectedSpots() 962 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 963 spot in selSpots]) 964 expandState = treeView.savedExpandState(saveSpots) 965 if selSpots.pasteSibling(self.structure): 966 treeView.restoreExpandState(expandState) 967 self.currentSelectionModel().selectSpots(selSpots, False) 968 self.updateAll() 969 globalref.mainControl.updateConfigDialog() 970 971 def editPasteAfter(self): 972 """Paste a sibling after selection. 973 """ 974 treeView = self.activeWindow.treeView 975 selSpots = self.currentSelectionModel().selectedSpots() 976 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 977 spot in selSpots]) 978 expandState = treeView.savedExpandState(saveSpots) 979 if selSpots.pasteSibling(self.structure, False): 980 treeView.restoreExpandState(expandState) 981 self.currentSelectionModel().selectSpots(selSpots, False) 982 self.updateAll() 983 globalref.mainControl.updateConfigDialog() 984 985 def editPasteCloneChild(self): 986 """Paste a child clone from the clipboard. 987 """ 988 if (self.currentSelectionModel().selectedSpots(). 989 pasteCloneChild(self.structure, self.activeWindow.treeView)): 990 self.updateAll() 991 992 def editPasteCloneBefore(self): 993 """Paste a sibling clone before selection. 994 """ 995 selSpots = self.currentSelectionModel().selectedSpots() 996 if selSpots.pasteCloneSibling(self.structure): 997 self.currentSelectionModel().selectSpots(selSpots, False) 998 self.updateAll() 999 1000 def editPasteCloneAfter(self): 1001 """Paste a sibling clone after selection. 1002 """ 1003 selSpots = self.currentSelectionModel().selectedSpots() 1004 if selSpots.pasteCloneSibling(self.structure, False): 1005 self.currentSelectionModel().selectSpots(selSpots, False) 1006 self.updateAll() 1007 1008 def nodeRename(self): 1009 """Start the rename editor in the selected tree node. 1010 """ 1011 if self.activeWindow.treeFilterView: 1012 self.activeWindow.treeFilterView.editItem(self.activeWindow. 1013 treeFilterView. 1014 currentItem()) 1015 else: 1016 self.activeWindow.treeView.endEditing() 1017 self.activeWindow.treeView.edit(self.currentSelectionModel(). 1018 currentIndex()) 1019 1020 def nodeAddChild(self): 1021 """Add new child to selected parent. 1022 """ 1023 self.activeWindow.treeView.endEditing() 1024 selSpots = self.currentSelectionModel().selectedSpots() 1025 newSpots = selSpots.addChild(self.structure, 1026 self.activeWindow.treeView) 1027 self.updateAll() 1028 if globalref.genOptions['RenameNewNodes']: 1029 self.currentSelectionModel().selectSpots(newSpots) 1030 if len(newSpots) == 1: 1031 self.activeWindow.treeView.edit(newSpots[0].index(self.model)) 1032 1033 def nodeInBefore(self): 1034 """Insert new sibling before selection. 1035 """ 1036 treeView = self.activeWindow.treeView 1037 treeView.endEditing() 1038 selSpots = self.currentSelectionModel().selectedSpots() 1039 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 1040 spot in selSpots]) 1041 expandState = treeView.savedExpandState(saveSpots) 1042 newSpots = selSpots.insertSibling(self.structure) 1043 treeView.restoreExpandState(expandState) 1044 self.updateAll() 1045 if globalref.genOptions['RenameNewNodes']: 1046 self.currentSelectionModel().selectSpots(newSpots) 1047 if len(newSpots) == 1: 1048 treeView.edit(newSpots[0].index(self.model)) 1049 1050 def nodeInAfter(self): 1051 """Insert new sibling after selection. 1052 """ 1053 treeView = self.activeWindow.treeView 1054 treeView.endEditing() 1055 selSpots = self.currentSelectionModel().selectedSpots() 1056 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 1057 spot in selSpots]) 1058 expandState = treeView.savedExpandState(saveSpots) 1059 newSpots = selSpots.insertSibling(self.structure, False) 1060 treeView.restoreExpandState(expandState) 1061 self.updateAll() 1062 if globalref.genOptions['RenameNewNodes']: 1063 self.currentSelectionModel().selectSpots(newSpots) 1064 if len(newSpots) == 1: 1065 treeView.edit(newSpots[0].index(self.model)) 1066 1067 def nodeDelete(self): 1068 """Delete the selected nodes. 1069 """ 1070 treeView = self.activeWindow.treeView 1071 selSpots = self.currentSelectionModel().selectedBranchSpots() 1072 if selSpots: 1073 # collapse deleted items to avoid crash 1074 for spot in selSpots: 1075 treeView.collapseSpot(spot) 1076 # clear hover to avoid crash if deleted child item was hovered over 1077 self.activeWindow.treeView.clearHover() 1078 # clear selection to avoid invalid multiple selection bug 1079 self.currentSelectionModel().selectSpots([], False) 1080 # clear selections in other windows that are about to be deleted 1081 for window in self.windowList: 1082 if window != self.activeWindow: 1083 selectModel = window.treeView.selectionModel() 1084 ancestors = set() 1085 for spot in selectModel.selectedBranchSpots(): 1086 ancestors.update(set(spot.spotChain())) 1087 if ancestors & set(selSpots): 1088 selectModel.selectSpots([], False) 1089 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 1090 spot in selSpots]) 1091 saveSpots = set(saveSpots) - set(selSpots) 1092 expandState = treeView.savedExpandState(saveSpots) 1093 nextSel = selSpots.delete(self.structure) 1094 treeView.restoreExpandState(expandState) 1095 self.currentSelectionModel().selectSpots([nextSel]) 1096 self.updateAll() 1097 1098 def nodeIndent(self): 1099 """Indent the selected nodes. 1100 1101 Makes them children of their previous siblings. 1102 """ 1103 treeView = self.activeWindow.treeView 1104 selSpots = self.currentSelectionModel().selectedSpots() 1105 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 1106 spot in selSpots]) 1107 expandState = treeView.savedExpandState(saveSpots) 1108 newSpots = selSpots.indent(self.structure) 1109 treeView.restoreExpandState(expandState) 1110 for spot in selSpots: 1111 treeView.expandSpot(spot.parentSpot) 1112 self.currentSelectionModel().selectSpots(newSpots, False) 1113 self.updateAll() 1114 1115 def nodeUnindent(self): 1116 """Unindent the selected nodes. 1117 1118 Makes them their parent's next sibling. 1119 """ 1120 treeView = self.activeWindow.treeView 1121 selSpots = self.currentSelectionModel().selectedSpots() 1122 saveSpots = chain.from_iterable([spot.parentSpot.childSpots() for 1123 spot in selSpots]) 1124 expandState = treeView.savedExpandState(saveSpots) 1125 newSpots = selSpots.unindent(self.structure) 1126 treeView.restoreExpandState(expandState) 1127 self.currentSelectionModel().selectSpots(newSpots, False) 1128 self.updateAll() 1129 1130 def nodeMoveUp(self): 1131 """Move the selected nodes upward in the sibling list. 1132 """ 1133 treeView = self.activeWindow.treeView 1134 selSpots = self.currentSelectionModel().selectedSpots() 1135 saveSpots = chain.from_iterable([(spot, spot.prevSiblingSpot()) for 1136 spot in selSpots]) 1137 expandState = treeView.savedExpandState(saveSpots) 1138 selSpots.move(self.structure) 1139 self.updateAll() 1140 treeView.restoreExpandState(expandState) 1141 self.currentSelectionModel().selectSpots(selSpots) 1142 1143 def nodeMoveDown(self): 1144 """Move the selected nodes downward in the sibling list. 1145 """ 1146 treeView = self.activeWindow.treeView 1147 selSpots = self.currentSelectionModel().selectedSpots() 1148 saveSpots = chain.from_iterable([(spot, spot.nextSiblingSpot()) for 1149 spot in selSpots]) 1150 expandState = treeView.savedExpandState(saveSpots) 1151 selSpots.move(self.structure, False) 1152 self.updateAll() 1153 treeView.restoreExpandState(expandState) 1154 self.currentSelectionModel().selectSpots(selSpots) 1155 1156 def nodeMoveFirst(self): 1157 """Move the selected nodes to be the first children. 1158 """ 1159 treeView = self.activeWindow.treeView 1160 selSpots = self.currentSelectionModel().selectedSpots() 1161 saveSpots = chain.from_iterable([(spot, 1162 spot.parentSpot.childSpots()[0]) 1163 for spot in selSpots]) 1164 expandState = treeView.savedExpandState(saveSpots) 1165 selSpots.moveToEnd(self.structure) 1166 self.updateAll() 1167 treeView.restoreExpandState(expandState) 1168 self.currentSelectionModel().selectSpots(selSpots) 1169 1170 def nodeMoveLast(self): 1171 """Move the selected nodes to be the last children. 1172 """ 1173 treeView = self.activeWindow.treeView 1174 selSpots = self.currentSelectionModel().selectedSpots() 1175 saveSpots = chain.from_iterable([(spot, 1176 spot.parentSpot.childSpots()[-1]) 1177 for spot in selSpots]) 1178 expandState = treeView.savedExpandState(saveSpots) 1179 selSpots.moveToEnd(self.structure, False) 1180 self.updateAll() 1181 treeView.restoreExpandState(expandState) 1182 self.currentSelectionModel().selectSpots(selSpots) 1183 1184 def dataSetType(self, action): 1185 """Change the type of selected nodes based on a menu selection. 1186 1187 Arguments: 1188 action -- the menu action containing the new type name 1189 """ 1190 newType = action.toolTip() # gives menu name without the accelerator 1191 nodes = [node for node in self.currentSelectionModel().selectedNodes() 1192 if node.formatRef.name != newType] 1193 if nodes: 1194 undo.TypeUndo(self.structure.undoList, nodes) 1195 for node in nodes: 1196 node.changeDataType(self.structure.treeFormats[newType]) 1197 self.updateAll() 1198 1199 def loadTypeSubMenu(self): 1200 """Update type select submenu with type names and check marks. 1201 """ 1202 selectTypeNames = set() 1203 typeLimitNames = set() 1204 for node in self.currentSelectionModel().selectedNodes(): 1205 selectTypeNames.add(node.formatRef.name) 1206 if typeLimitNames is not None: 1207 for parent in node.parents(): 1208 limit = (parent.formatRef.childTypeLimit if 1209 parent.formatRef else None) 1210 if (not limit or (typeLimitNames and 1211 limit != typeLimitNames)): 1212 typeLimitNames = None 1213 elif typeLimitNames is not None: 1214 typeLimitNames = limit 1215 if typeLimitNames: 1216 typeNames = sorted(list(typeLimitNames)) 1217 else: 1218 typeNames = self.structure.treeFormats.typeNames() 1219 self.typeSubMenu.clear() 1220 usedShortcuts = [] 1221 for name in typeNames: 1222 shortcutPos = 0 1223 try: 1224 while [shortcutPos] in usedShortcuts: 1225 shortcutPos += 1 1226 usedShortcuts.append(name[shortcutPos]) 1227 text = '{0}&{1}'.format(name[:shortcutPos], name[shortcutPos:]) 1228 except IndexError: 1229 text = name 1230 action = self.typeSubMenu.addAction(text) 1231 action.setCheckable(True) 1232 if name in selectTypeNames: 1233 action.setChecked(True) 1234 1235 def showTypeContextMenu(self): 1236 """Show a type set menu at the current tree view item. 1237 """ 1238 self.activeWindow.treeView.showTypeMenu(self.typeSubMenu) 1239 1240 def dataCopyType(self): 1241 """Copy the configuration from another TreeLine file. 1242 """ 1243 filters = ';;'.join((globalref.fileFilters['trlnv3'], 1244 globalref.fileFilters['all'])) 1245 fileName, selectFilter = QFileDialog.getOpenFileName(self.activeWindow, 1246 _('TreeLine - Open Configuration File'), 1247 str(globalref.mainControl. 1248 defaultPathObj(True)), filters) 1249 if not fileName: 1250 return 1251 QApplication.setOverrideCursor(Qt.WaitCursor) 1252 newStructure = None 1253 try: 1254 with open(fileName, 'r', encoding='utf-8') as f: 1255 fileData = json.load(f) 1256 newStructure = treestructure.TreeStructure(fileData, 1257 addSpots=False) 1258 except IOError: 1259 pass 1260 except (ValueError, KeyError, TypeError): 1261 fileObj = open(fileName, 'rb') 1262 fileObj, encrypted = globalref.mainControl.decryptFile(fileObj) 1263 if not fileObj: 1264 QApplication.restoreOverrideCursor() 1265 return 1266 fileObj, compressed = globalref.mainControl.decompressFile(fileObj) 1267 if compressed or encrypted: 1268 try: 1269 textFileObj = io.TextIOWrapper(fileObj, encoding='utf-8') 1270 fileData = json.load(textFileObj) 1271 textFileObj.close() 1272 newStructure = treestructure.TreeStructure(fileData, 1273 addSpots=False) 1274 except (ValueError, KeyError, TypeError): 1275 pass 1276 fileObj.close() 1277 if not newStructure: 1278 QApplication.restoreOverrideCursor() 1279 QMessageBox.warning(self.activeWindow, 'TreeLine', 1280 _('Error - could not read file {0}'). 1281 format(fileName)) 1282 return 1283 undo.FormatUndo(self.structure.undoList, self.structure.treeFormats, 1284 treeformats.TreeFormats()) 1285 for nodeFormat in newStructure.treeFormats.values(): 1286 self.structure.treeFormats.addTypeIfMissing(nodeFormat) 1287 QApplication.restoreOverrideCursor() 1288 self.updateAll() 1289 globalref.mainControl.updateConfigDialog() 1290 1291 def dataRegenRefs(self): 1292 """Force update of all conditional types & math fields. 1293 """ 1294 self.updateAll(False) 1295 1296 def dataCloneMatches(self): 1297 """Convert all matching nodes into clones. 1298 """ 1299 QApplication.setOverrideCursor(Qt.WaitCursor) 1300 selSpots = self.currentSelectionModel().selectedSpots() 1301 titleDict = {} 1302 for node in self.structure.nodeDict.values(): 1303 titleDict.setdefault(node.title(), set()).add(node) 1304 undoObj = undo.ChildListUndo(self.structure.undoList, 1305 self.structure.childList, addBranch=True) 1306 numChanges = 0 1307 for node in self.structure.descendantGen(): 1308 matches = titleDict[node.title()] 1309 if len(matches) > 1: 1310 matches = matches.copy() 1311 matches.remove(node) 1312 for matchedNode in matches: 1313 if node.isIdentical(matchedNode): 1314 numChanges += 1 1315 if len(matchedNode.spotRefs) > len(node.spotRefs): 1316 tmpNode = node 1317 node = matchedNode 1318 matchedNode = tmpNode 1319 numSpots = len(matchedNode.spotRefs) 1320 for parent in matchedNode.parents(): 1321 pos = parent.childList.index(matchedNode) 1322 parent.childList[pos] = node 1323 node.addSpotRef(parent) 1324 for child in matchedNode.descendantGen(): 1325 if len(child.spotRefs) <= numSpots: 1326 titleDict[child.title()].remove(child) 1327 self.structure.removeNodeDictRef(child) 1328 child.removeInvalidSpotRefs(False) 1329 if numChanges: 1330 msg = _('Converted {0} branches into clones').format(numChanges) 1331 self.currentSelectionModel().selectSpots([spot for spot in selSpots 1332 if spot.isValid()], 1333 False) 1334 self.updateAll() 1335 else: 1336 msg = _('No identical nodes found') 1337 self.structure.undoList.removeLastUndo(undoObj) 1338 QApplication.restoreOverrideCursor() 1339 QMessageBox.information(self.activeWindow, 'TreeLine', msg) 1340 1341 def dataDetachClones(self): 1342 """Detach all cloned nodes in current branches. 1343 """ 1344 QApplication.setOverrideCursor(Qt.WaitCursor) 1345 selSpots = self.currentSelectionModel().selectedBranchSpots() 1346 undoObj = undo.ChildListUndo(self.structure.undoList, 1347 [spot.parentSpot.nodeRef for spot in 1348 selSpots], addBranch=True) 1349 numChanges = 0 1350 for branchSpot in selSpots: 1351 for spot in branchSpot.spotDescendantGen(): 1352 if (len(spot.nodeRef.spotRefs) > 1 and 1353 len(spot.parentSpot.nodeRef.spotRefs) <= 1): 1354 numChanges += 1 1355 linkedNode = spot.nodeRef 1356 linkedNode.spotRefs.remove(spot) 1357 newNode = treenode.TreeNode(linkedNode.formatRef) 1358 newNode.data = linkedNode.data.copy() 1359 newNode.childList = linkedNode.childList[:] 1360 newNode.spotRefs.add(spot) 1361 spot.nodeRef = newNode 1362 parent = spot.parentSpot.nodeRef 1363 pos = parent.childList.index(linkedNode) 1364 parent.childList[pos] = newNode 1365 self.structure.addNodeDictRef(newNode) 1366 if numChanges: 1367 self.updateAll() 1368 else: 1369 self.structure.undoList.removeLastUndo(undoObj) 1370 QApplication.restoreOverrideCursor() 1371 1372 def dataFlatCategory(self): 1373 """Collapse descendant nodes by merging fields. 1374 1375 Overwrites data in any fields with the same name. 1376 """ 1377 QApplication.setOverrideCursor(Qt.WaitCursor) 1378 selectList = self.currentSelectionModel().selectedBranches() 1379 undo.ChildDataUndo(self.structure.undoList, selectList, True, 1380 self.structure.treeFormats) 1381 origFormats = self.structure.undoList[-1].treeFormats 1382 for node in selectList: 1383 node.flatChildCategory(origFormats, self.structure) 1384 self.updateAll() 1385 globalref.mainControl.updateConfigDialog() 1386 QApplication.restoreOverrideCursor() 1387 1388 def dataAddCategory(self): 1389 """Insert category nodes above children. 1390 """ 1391 selectList = self.currentSelectionModel().selectedBranches() 1392 children = [] 1393 for node in selectList: 1394 children.extend(node.childList) 1395 fieldList = self.structure.treeFormats.commonFields(children) 1396 if not fieldList: 1397 QMessageBox.warning(self.activeWindow, 'TreeLine', 1398 _('Cannot expand without common fields')) 1399 return 1400 dialog = miscdialogs.FieldSelectDialog(_('Category Fields'), 1401 _('Select fields for new level'), 1402 fieldList, self.activeWindow) 1403 if dialog.exec_() != QDialog.Accepted: 1404 return 1405 QApplication.setOverrideCursor(Qt.WaitCursor) 1406 undo.ChildDataUndo(self.structure.undoList, selectList, True, 1407 self.structure.treeFormats) 1408 for node in selectList: 1409 node.addChildCategory(dialog.selectedFields, self.structure) 1410 self.updateAll() 1411 globalref.mainControl.updateConfigDialog() 1412 QApplication.restoreOverrideCursor() 1413 1414 def dataSwapCategory(self): 1415 """Swap child and grandchild category nodes. 1416 """ 1417 QApplication.setOverrideCursor(Qt.WaitCursor) 1418 selectList = self.currentSelectionModel().selectedBranches() 1419 undo.ChildListUndo(self.structure.undoList, selectList, addBranch=True) 1420 doneNodes = set() 1421 for ancestor in selectList: 1422 for child in ancestor.childList[:]: 1423 for catNode in child.childList[:]: 1424 if catNode not in doneNodes: 1425 doneNodes.add(catNode) 1426 childSpots = [spot.parentSpot for spot in 1427 catNode.spotRefs] 1428 childSpots.sort(key=operator.methodcaller('sortKey')) 1429 children = [childSpot.nodeRef for childSpot in 1430 childSpots] 1431 catNode.childList[0:0] = children 1432 for ancestor in selectList: 1433 position = 0 1434 doneNodes = set() 1435 for child in ancestor.childList[:]: 1436 for catNode in child.childList[:]: 1437 if catNode not in doneNodes: 1438 doneNodes.add(catNode) 1439 for catSpot in catNode.spotRefs: 1440 child = catSpot.parentSpot.nodeRef 1441 child.childList = [] 1442 if child in ancestor.childList: 1443 position = ancestor.childList.index(child) 1444 del ancestor.childList[position] 1445 ancestor.childList.insert(position, catNode) 1446 position += 1 1447 catNode.addSpotRef(ancestor) 1448 catNode.removeInvalidSpotRefs() 1449 self.updateAll() 1450 QApplication.restoreOverrideCursor() 1451 1452 def toolsSpellCheck(self): 1453 """Spell check the tree text data. 1454 """ 1455 try: 1456 spellCheckOp = spellcheck.SpellCheckOperation(self) 1457 except spellcheck.SpellCheckError: 1458 return 1459 spellCheckOp.spellCheck() 1460 1461 def findNodesByWords(self, wordList, titlesOnly=False, forward=True): 1462 """Search for and select nodes that match the word list criteria. 1463 1464 Called from the text find dialog. 1465 Returns True if found, otherwise False. 1466 Arguments: 1467 wordList -- a list of words or phrases to find 1468 titleOnly -- search only in the title text if True 1469 forward -- next if True, previous if False 1470 """ 1471 currentSpot = self.currentSelectionModel().currentSpot() 1472 spot = currentSpot 1473 while True: 1474 if self.activeWindow.treeFilterView: 1475 spot = self.activeWindow.treeFilterView.nextPrevSpot(spot, 1476 forward) 1477 else: 1478 if forward: 1479 spot = spot.nextTreeSpot(True) 1480 else: 1481 spot = spot.prevTreeSpot(True) 1482 if spot is currentSpot: 1483 return False 1484 if spot.nodeRef.wordSearch(wordList, titlesOnly, spot): 1485 self.currentSelectionModel().selectSpots([spot], True, True) 1486 rightView = self.activeWindow.rightParentView() 1487 if not rightView: 1488 # view update required if (and only if) view is newly shown 1489 QApplication.processEvents() 1490 rightView = self.activeWindow.rightParentView() 1491 if rightView: 1492 rightView.highlightSearch(wordList=wordList) 1493 QApplication.processEvents() 1494 return True 1495 1496 def findNodesByRegExp(self, regExpList, titlesOnly=False, forward=True): 1497 """Search for and select nodes that match the regular exp criteria. 1498 1499 Called from the text find dialog. 1500 Returns True if found, otherwise False. 1501 Arguments: 1502 regExpList -- a list of regular expression objects 1503 titleOnly -- search only in the title text if True 1504 forward -- next if True, previous if False 1505 """ 1506 currentSpot = self.currentSelectionModel().currentSpot() 1507 spot = currentSpot 1508 while True: 1509 if self.activeWindow.treeFilterView: 1510 spot = self.activeWindow.treeFilterView.nextPrevSpot(spot, 1511 forward) 1512 else: 1513 if forward: 1514 spot = spot.nextTreeSpot(True) 1515 else: 1516 spot = spot.prevTreeSpot(True) 1517 if spot is currentSpot: 1518 return False 1519 if spot.nodeRef.regExpSearch(regExpList, titlesOnly, spot): 1520 self.currentSelectionModel().selectSpots([spot], True, True) 1521 rightView = self.activeWindow.rightParentView() 1522 if not rightView: 1523 # view update required if (and only if) view is newly shown 1524 QApplication.processEvents() 1525 rightView = self.activeWindow.rightParentView() 1526 if rightView: 1527 rightView.highlightSearch(regExpList=regExpList) 1528 return True 1529 1530 def findNodesByCondition(self, conditional, forward=True): 1531 """Search for and select nodes that match the regular exp criteria. 1532 1533 Called from the conditional find dialog. 1534 Returns True if found, otherwise False. 1535 Arguments: 1536 conditional -- the Conditional object to be evaluated 1537 forward -- next if True, previous if False 1538 """ 1539 currentSpot = self.currentSelectionModel().currentSpot() 1540 spot = currentSpot 1541 while True: 1542 if self.activeWindow.treeFilterView: 1543 spot = self.activeWindow.treeFilterView.nextPrevSpot(spot, 1544 forward) 1545 else: 1546 if forward: 1547 spot = spot.nextTreeSpot(True) 1548 else: 1549 spot = spot.prevTreeSpot(True) 1550 if spot is currentSpot: 1551 return False 1552 if conditional.evaluate(spot.nodeRef): 1553 self.currentSelectionModel().selectSpots([spot], True, True) 1554 return True 1555 1556 def findNodesForReplace(self, searchText='', regExpObj=None, typeName='', 1557 fieldName='', forward=True): 1558 """Search for & select nodes that match the criteria prior to replace. 1559 1560 Called from the find replace dialog. 1561 Returns True if found, otherwise False. 1562 Arguments: 1563 searchText -- the text to find if no regexp is given 1564 regExpObj -- the regular expression to find if given 1565 typeName -- if given, verify that this node matches this type 1566 fieldName -- if given, only find matches under this type name 1567 forward -- next if True, previous if False 1568 """ 1569 currentSpot = self.currentSelectionModel().currentSpot() 1570 lastFoundSpot, currentNumMatches = self.findReplaceSpotRef 1571 numMatches = currentNumMatches 1572 if lastFoundSpot is not currentSpot: 1573 numMatches = 0 1574 spot = currentSpot 1575 if not forward: 1576 if numMatches == 0: 1577 numMatches = -1 # find last one if backward 1578 elif numMatches == 1: 1579 numMatches = sys.maxsize # no match if on first one 1580 else: 1581 numMatches -= 2 1582 while True: 1583 matchedField, numMatches, fieldPos = (spot.nodeRef. 1584 searchReplace(searchText, 1585 regExpObj, 1586 numMatches, 1587 typeName, 1588 fieldName)) 1589 if matchedField: 1590 fieldNum = (spot.nodeRef.formatRef.fieldNames(). 1591 index(matchedField)) 1592 self.currentSelectionModel().selectSpots([spot], True, True) 1593 self.activeWindow.rightTabs.setCurrentWidget(self.activeWindow. 1594 editorSplitter) 1595 dataView = self.activeWindow.rightParentView() 1596 if not dataView: 1597 # view update required if (and only if) view is newly shown 1598 QApplication.processEvents() 1599 dataView = self.activeWindow.rightParentView() 1600 if dataView: 1601 dataView.highlightMatch(searchText, regExpObj, fieldNum, 1602 fieldPos - 1) 1603 self.findReplaceSpotRef = (spot, numMatches) 1604 return True 1605 if self.activeWindow.treeFilterView: 1606 node = self.activeWindow.treeFilterView.nextPrevSpot(spot, 1607 forward) 1608 else: 1609 if forward: 1610 spot = spot.nextTreeSpot(True) 1611 else: 1612 spot = spot.prevTreeSpot(True) 1613 if spot is currentSpot and currentNumMatches == 0: 1614 self.findReplaceSpotRef = (None, 0) 1615 return False 1616 numMatches = 0 if forward else -1 1617 1618 def replaceInCurrentNode(self, searchText='', regExpObj=None, typeName='', 1619 fieldName='', replaceText=None): 1620 """Replace the current match in the current node. 1621 1622 Called from the find replace dialog. 1623 Returns True if replaced, otherwise False. 1624 Arguments: 1625 searchText -- the text to find if no regexp is given 1626 regExpObj -- the regular expression to find if given 1627 typeName -- if given, verify that this node matches this type 1628 fieldName -- if given, only find matches under this type name 1629 replaceText -- if not None, replace a match with this string 1630 """ 1631 spot = self.currentSelectionModel().currentSpot() 1632 lastFoundSpot, numMatches = self.findReplaceSpotRef 1633 if numMatches > 0: 1634 numMatches -= 1 1635 if lastFoundSpot is not spot: 1636 numMatches = 0 1637 dataUndo = undo.DataUndo(self.structure.undoList, spot.nodeRef) 1638 matchedField, num1, num2 = (spot.nodeRef. 1639 searchReplace(searchText, regExpObj, 1640 numMatches, typeName, 1641 fieldName, replaceText)) 1642 if ((searchText and searchText in replaceText) or 1643 (regExpObj and r'\g<0>' in replaceText) or 1644 (regExpObj and regExpObj.pattern.startswith('(') and 1645 regExpObj.pattern.endswith(')') and r'\1' in replaceText)): 1646 numMatches += 1 # check for recursive matches 1647 self.findReplaceSpotRef = (spot, numMatches) 1648 if matchedField: 1649 self.updateTreeNode(spot.nodeRef) 1650 self.updateRightViews() 1651 return True 1652 self.structure.undoList.removeLastUndo(dataUndo) 1653 return False 1654 1655 def replaceAll(self, searchText='', regExpObj=None, typeName='', 1656 fieldName='', replaceText=None): 1657 """Replace all matches in all nodes. 1658 1659 Called from the find replace dialog. 1660 Returns number of matches replaced. 1661 Arguments: 1662 searchText -- the text to find if no regexp is given 1663 regExpObj -- the regular expression to find if given 1664 typeName -- if given, verify that this node matches this type 1665 fieldName -- if given, only find matches under this type name 1666 replaceText -- if not None, replace a match with this string 1667 """ 1668 QApplication.setOverrideCursor(Qt.WaitCursor) 1669 dataUndo = undo.DataUndo(self.structure.undoList, 1670 self.structure.childList, addBranch=True) 1671 totalMatches = 0 1672 for node in self.structure.nodeDict.values(): 1673 field, matchQty, num = node.searchReplace(searchText, regExpObj, 1674 0, typeName, fieldName, 1675 replaceText, True) 1676 totalMatches += matchQty 1677 self.findReplaceSpotRef = (None, 0) 1678 if totalMatches > 0: 1679 self.updateAll(True) 1680 else: 1681 self.structure.undoList.removeLastUndo(dataUndo) 1682 QApplication.restoreOverrideCursor() 1683 return totalMatches 1684 1685 def windowNew(self, checked=False, offset=30): 1686 """Open a new window for this file. 1687 1688 Arguments: 1689 checked -- unused parameter needed by QAction signal 1690 offset -- location offset from previously saved position 1691 """ 1692 window = treewindow.TreeWindow(self.model, self.allActions) 1693 self.setWindowSignals(window) 1694 window.winMinimized.connect(globalref.mainControl.trayMinimize) 1695 self.windowList.append(window) 1696 self.updateWindowCaptions() 1697 oldControl = globalref.mainControl.activeControl 1698 if oldControl: 1699 try: 1700 oldControl.activeWindow.saveWindowGeom() 1701 except RuntimeError: 1702 # possibly avoid rare error of deleted c++ TreeWindow 1703 pass 1704 window.restoreWindowGeom(offset) 1705 self.activeWindow = window 1706 self.expandRootNodes() 1707 self.selectRootSpot() 1708 window.show() 1709 window.updateRightViews() 1710