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