Fix merge conflicts
This commit is contained in:
@@ -8,9 +8,12 @@ from nw.gui.theme import GuiTheme
|
||||
|
||||
# Dialogs
|
||||
from nw.gui.dialogs.configeditor import GuiConfigEditor
|
||||
from nw.gui.dialogs.docmerge import GuiDocMerge
|
||||
from nw.gui.dialogs.docsplit import GuiDocSplit
|
||||
from nw.gui.dialogs.export import GuiExport
|
||||
from nw.gui.dialogs.itemeditor import GuiItemEditor
|
||||
from nw.gui.dialogs.projecteditor import GuiProjectEditor
|
||||
from nw.gui.dialogs.projectload import GuiProjectLoad
|
||||
from nw.gui.dialogs.sessionlog import GuiSessionLogView
|
||||
|
||||
# GUI Elements
|
||||
@@ -33,9 +36,12 @@ __all__ = [
|
||||
"GuiMainStatus",
|
||||
"GuiTheme",
|
||||
"GuiConfigEditor",
|
||||
"GuiDocMerge",
|
||||
"GuiDocSplit",
|
||||
"GuiExport",
|
||||
"GuiItemEditor",
|
||||
"GuiProjectEditor",
|
||||
"GuiProjectLoad",
|
||||
"GuiSessionLogView",
|
||||
"GuiDocDetails",
|
||||
"GuiDocEditor",
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from nw.gui.dialogs.configeditor import GuiConfigEditor
|
||||
from nw.gui.dialogs.docmerge import GuiDocMerge
|
||||
from nw.gui.dialogs.docsplit import GuiDocSplit
|
||||
from nw.gui.dialogs.export import GuiExport
|
||||
from nw.gui.dialogs.itemeditor import GuiItemEditor
|
||||
from nw.gui.dialogs.projecteditor import GuiProjectEditor
|
||||
from nw.gui.dialogs.projectload import GuiProjectLoad
|
||||
from nw.gui.dialogs.sessionlog import GuiSessionLogView
|
||||
|
||||
__all__ = [
|
||||
"GuiConfigEditor",
|
||||
"GuiDocMerge",
|
||||
"GuiDocSplit",
|
||||
"GuiExport",
|
||||
"GuiItemEditor",
|
||||
"GuiProjectEditor",
|
||||
"GuiProjectLoad",
|
||||
"GuiSessionLogView",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""novelWriter GUI Doc Merge
|
||||
|
||||
novelWriter – GUI Doc Merge
|
||||
=============================
|
||||
Tool for merging multiple documents to one
|
||||
|
||||
File History:
|
||||
Created: 2020-01-23 [0.4.3]
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import nw
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton,
|
||||
QListWidget, QAbstractItemView, QListWidgetItem
|
||||
)
|
||||
from nw.constants import nwAlert, nwItemType
|
||||
from nw.project import NWDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GuiDocMerge(QDialog):
|
||||
|
||||
def __init__(self, theParent, theProject):
|
||||
QDialog.__init__(self, theParent)
|
||||
|
||||
logger.debug("Initialising GuiDocMerge ...")
|
||||
|
||||
self.mainConf = nw.CONFIG
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.sourceItem = None
|
||||
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.innerBox = QVBoxLayout()
|
||||
self.setWindowTitle("Merge Documents")
|
||||
self.setLayout(self.outerBox)
|
||||
|
||||
self.guiDeco = self.theParent.theTheme.loadDecoration("merge",(64,64))
|
||||
|
||||
self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
|
||||
self.outerBox.addLayout(self.innerBox)
|
||||
|
||||
self.doMergeForm = QGridLayout()
|
||||
self.doMergeForm.setContentsMargins(0,0,0,0)
|
||||
|
||||
self.listBox = QListWidget()
|
||||
self.listBox.setDragDropMode(QAbstractItemView.InternalMove)
|
||||
|
||||
self.mergeButton = QPushButton("Merge")
|
||||
self.mergeButton.clicked.connect(self._doMerge)
|
||||
|
||||
self.closeButton = QPushButton("Close")
|
||||
self.closeButton.clicked.connect(self._doClose)
|
||||
|
||||
self.doMergeForm.addWidget(self.listBox, 0, 0, 1, 3)
|
||||
self.doMergeForm.addWidget(self.mergeButton, 1, 1)
|
||||
self.doMergeForm.addWidget(self.closeButton, 1, 2)
|
||||
|
||||
self.innerBox.addLayout(self.doMergeForm)
|
||||
|
||||
self.rejected.connect(self._doClose)
|
||||
self.show()
|
||||
|
||||
self._populateList()
|
||||
|
||||
logger.debug("GuiDocMerge initialisation complete")
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Buttons
|
||||
##
|
||||
|
||||
def _doMerge(self):
|
||||
"""Perform the merge of the files in the selected folder, and
|
||||
create a new file in the same parent folder. The old files are
|
||||
not removed in the merge process, and must be deleted manually.
|
||||
"""
|
||||
|
||||
logger.verbose("GuiDocMerge merge button clicked")
|
||||
|
||||
finalOrder = []
|
||||
for i in range(self.listBox.count()):
|
||||
finalOrder.append(self.listBox.item(i).data(Qt.UserRole))
|
||||
|
||||
theDoc = NWDoc(self.theProject, self.theParent)
|
||||
theText = ""
|
||||
for tHandle in finalOrder:
|
||||
theText += theDoc.openDocument(tHandle, False).rstrip()
|
||||
theText += "\n\n"
|
||||
|
||||
if self.sourceItem is None:
|
||||
self.theParent.makeAlert((
|
||||
"Cannot parse source item."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
srcItem = self.theProject.getItem(self.sourceItem)
|
||||
nHandle = self.theProject.newFile(srcItem.itemName, srcItem.itemClass, srcItem.parHandle)
|
||||
self.theParent.treeView.revealTreeItem(nHandle)
|
||||
theDoc.openDocument(nHandle, False)
|
||||
theDoc.saveDocument(theText)
|
||||
self.theParent.openDocument(nHandle)
|
||||
|
||||
self.close()
|
||||
|
||||
return
|
||||
|
||||
def _doClose(self):
|
||||
"""Close the dialog window without doing anything.
|
||||
"""
|
||||
logger.verbose("GuiDocMerge close button clicked")
|
||||
self.close()
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _populateList(self):
|
||||
"""Get the item selected in the tree, check that it is a folder,
|
||||
and try to find all files associated with it. The valid files
|
||||
are then added to the list view in order. The list itself can be
|
||||
reordered by the user.
|
||||
"""
|
||||
|
||||
tHandle = self.theParent.treeView.getSelectedHandle()
|
||||
self.sourceItem = tHandle
|
||||
if tHandle is None:
|
||||
return
|
||||
|
||||
nwItem = self.theProject.getItem(tHandle)
|
||||
if nwItem is None:
|
||||
return
|
||||
if nwItem.itemType is not nwItemType.FOLDER:
|
||||
self.theParent.makeAlert((
|
||||
"Element selected in the project tree must be a folder."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
for sHandle in self.theParent.treeView.getTreeFromHandle(tHandle):
|
||||
newItem = QListWidgetItem()
|
||||
nwItem = self.theProject.getItem(sHandle)
|
||||
if nwItem.itemType is not nwItemType.FILE:
|
||||
continue
|
||||
newItem.setText(nwItem.itemName)
|
||||
newItem.setData(Qt.UserRole, sHandle)
|
||||
self.listBox.addItem(newItem)
|
||||
|
||||
return
|
||||
|
||||
# END Class GuiDocMerge
|
||||
@@ -0,0 +1,241 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""novelWriter GUI Doc Split
|
||||
|
||||
novelWriter – GUI Doc Split
|
||||
=============================
|
||||
Tool for splitting a single document into multiple documents
|
||||
|
||||
File History:
|
||||
Created: 2020-02-01 [0.4.3]
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import nw
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QComboBox,
|
||||
QListWidget, QAbstractItemView, QListWidgetItem
|
||||
)
|
||||
from nw.constants import nwAlert, nwItemType, nwItemClass, nwItemLayout
|
||||
from nw.project import NWDoc
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GuiDocSplit(QDialog):
|
||||
|
||||
def __init__(self, theParent, theProject):
|
||||
QDialog.__init__(self, theParent)
|
||||
|
||||
logger.debug("Initialising GuiDocSplit ...")
|
||||
|
||||
self.mainConf = nw.CONFIG
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.optState = self.theProject.optState
|
||||
self.sourceItem = None
|
||||
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.innerBox = QVBoxLayout()
|
||||
self.setWindowTitle("Split Document")
|
||||
self.setLayout(self.outerBox)
|
||||
|
||||
self.guiDeco = self.theParent.theTheme.loadDecoration("split",(64,64))
|
||||
|
||||
self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
|
||||
self.outerBox.addLayout(self.innerBox)
|
||||
|
||||
self.doMergeForm = QGridLayout()
|
||||
self.doMergeForm.setContentsMargins(0,0,0,0)
|
||||
|
||||
self.listBox = QListWidget()
|
||||
self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
|
||||
|
||||
self.splitLevel = QComboBox(self)
|
||||
self.splitLevel.addItem("Split on Header Level 1 (Title)", 1)
|
||||
self.splitLevel.addItem("Split up to Header Level 2 (Chapter)", 2)
|
||||
self.splitLevel.addItem("Split up to Header Level 3 (Scene)", 3)
|
||||
self.splitLevel.addItem("Split up to Header Level 4 (Section)", 4)
|
||||
spIndex = self.splitLevel.findData(
|
||||
self.optState.getInt("GuiDocSplit", "spLevel", 3)
|
||||
)
|
||||
if spIndex != -1:
|
||||
self.splitLevel.setCurrentIndex(spIndex)
|
||||
self.splitLevel.currentIndexChanged.connect(self._populateList)
|
||||
|
||||
self.splitButton = QPushButton("Split")
|
||||
self.splitButton.clicked.connect(self._doSplit)
|
||||
|
||||
self.closeButton = QPushButton("Close")
|
||||
self.closeButton.clicked.connect(self._doClose)
|
||||
|
||||
self.doMergeForm.addWidget(self.listBox, 0, 0, 1, 3)
|
||||
self.doMergeForm.addWidget(self.splitLevel, 1, 0, 1, 3)
|
||||
self.doMergeForm.addWidget(self.splitButton, 2, 1)
|
||||
self.doMergeForm.addWidget(self.closeButton, 2, 2)
|
||||
|
||||
self.innerBox.addLayout(self.doMergeForm)
|
||||
|
||||
self.rejected.connect(self._doClose)
|
||||
self.show()
|
||||
|
||||
self._populateList()
|
||||
|
||||
logger.debug("GuiDocSplit initialisation complete")
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Buttons
|
||||
##
|
||||
|
||||
def _doSplit(self):
|
||||
"""Perform the split of the file, create a new folder in the
|
||||
same parent folder, and multiple files depending on split level
|
||||
settings. The old file is not removed in the merge process, and
|
||||
must be deleted manually.
|
||||
"""
|
||||
|
||||
logger.verbose("GuiDocSplit split button clicked")
|
||||
|
||||
if self.sourceItem is None:
|
||||
self.theParent.makeAlert((
|
||||
"No source document selected. Nothing to do."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
srcItem = self.theProject.getItem(self.sourceItem)
|
||||
if srcItem is None:
|
||||
self.theParent.makeAlert((
|
||||
"Could not parse source document."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
theDoc = NWDoc(self.theProject, self.theParent)
|
||||
theText = theDoc.openDocument(self.sourceItem, False)
|
||||
theLines = theText.splitlines()
|
||||
nLines = len(theLines)
|
||||
theLines.insert(0, "%Split Doc")
|
||||
logger.debug(
|
||||
"Splitting document %s with %d lines" % (self.sourceItem,nLines)
|
||||
)
|
||||
|
||||
finalOrder = []
|
||||
for i in range(self.listBox.count()):
|
||||
listItem = self.listBox.item(i)
|
||||
wTitle = listItem.text()
|
||||
lineNo = listItem.data(Qt.UserRole)
|
||||
finalOrder.append([wTitle, lineNo, nLines])
|
||||
if i > 0:
|
||||
finalOrder[i-1][2] = lineNo
|
||||
|
||||
if len(finalOrder) == 0:
|
||||
self.theParent.makeAlert((
|
||||
"No headers found. Nothing to do."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
fHandle = self.theProject.newFolder(srcItem.itemName, srcItem.itemClass, srcItem.parHandle)
|
||||
self.theParent.treeView.revealTreeItem(fHandle)
|
||||
logger.verbose("Creating folder %s" % fHandle)
|
||||
|
||||
for wTitle, iStart, iEnd in finalOrder:
|
||||
|
||||
itemLayout = nwItemLayout.NOTE
|
||||
if srcItem.itemClass == nwItemClass.NOVEL:
|
||||
if wTitle.startswith("# "):
|
||||
itemLayout = nwItemLayout.PARTITION
|
||||
elif wTitle.startswith("## "):
|
||||
itemLayout = nwItemLayout.CHAPTER
|
||||
elif wTitle.startswith("### "):
|
||||
itemLayout = nwItemLayout.SCENE
|
||||
elif wTitle.startswith("#### "):
|
||||
itemLayout = nwItemLayout.PAGE
|
||||
|
||||
wTitle = wTitle.lstrip("#")
|
||||
wTitle = wTitle.strip()
|
||||
|
||||
nHandle = self.theProject.newFile(wTitle, srcItem.itemClass, fHandle)
|
||||
newItem = self.theProject.getItem(nHandle)
|
||||
newItem.setLayout(itemLayout)
|
||||
logger.verbose(
|
||||
"Creating new document %s with text from line %d to %d" % (nHandle, iStart, iEnd-1)
|
||||
)
|
||||
|
||||
theText = "\n".join(theLines[iStart:iEnd])
|
||||
theDoc.openDocument(nHandle, False)
|
||||
theDoc.saveDocument(theText)
|
||||
theDoc.clearDocument()
|
||||
self.theParent.treeView.revealTreeItem(nHandle)
|
||||
|
||||
self.close()
|
||||
|
||||
return
|
||||
|
||||
def _doClose(self):
|
||||
"""Close the dialog window without doing anything.
|
||||
"""
|
||||
logger.verbose("GuiDocSplit close button clicked")
|
||||
self.optState.saveSettings()
|
||||
self.close()
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _populateList(self):
|
||||
"""Get the item selected in the tree, check that it is a folder,
|
||||
and try to find all files associated with it. The valid files
|
||||
are then added to the list view in order. The list itself can be
|
||||
reordered by the user.
|
||||
"""
|
||||
|
||||
if self.sourceItem is None:
|
||||
self.sourceItem = self.theParent.treeView.getSelectedHandle()
|
||||
|
||||
if self.sourceItem is None:
|
||||
return
|
||||
|
||||
nwItem = self.theProject.getItem(self.sourceItem)
|
||||
if nwItem is None:
|
||||
return
|
||||
if nwItem.itemType is not nwItemType.FILE:
|
||||
self.theParent.makeAlert((
|
||||
"Element selected in the project tree must be a file."
|
||||
), nwAlert.ERROR)
|
||||
return
|
||||
|
||||
self.listBox.clear()
|
||||
theDoc = NWDoc(self.theProject, self.theParent)
|
||||
theText = theDoc.openDocument(self.sourceItem, False)
|
||||
|
||||
spLevel = self.splitLevel.currentData()
|
||||
self.optState.setValue("GuiDocSplit", "spLevel", spLevel)
|
||||
logger.debug("Scanning document %s for headings level <= %d" % (self.sourceItem, spLevel))
|
||||
|
||||
lineNo = 0
|
||||
for aLine in theText.splitlines():
|
||||
|
||||
lineNo += 1
|
||||
onLine = 0
|
||||
|
||||
if aLine.startswith("# ") and spLevel >= 1:
|
||||
onLine = lineNo
|
||||
elif aLine.startswith("## ") and spLevel >= 2:
|
||||
onLine = lineNo
|
||||
elif aLine.startswith("### ") and spLevel >= 3:
|
||||
onLine = lineNo
|
||||
elif aLine.startswith("#### ") and spLevel >= 4:
|
||||
onLine = lineNo
|
||||
|
||||
if onLine > 0:
|
||||
newItem = QListWidgetItem()
|
||||
newItem.setText(aLine.strip())
|
||||
newItem.setData(Qt.UserRole, onLine)
|
||||
self.listBox.addItem(newItem)
|
||||
|
||||
return
|
||||
|
||||
# END Class GuiDocSplit
|
||||
+63
-65
@@ -24,7 +24,6 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from nw.convert import TextFile, HtmlFile, MarkdownFile, LaTeXFile, ConcatFile
|
||||
from nw.tools import OptLastState
|
||||
from nw.common import packageRefURL
|
||||
from nw.constants import nwFiles, nwItemType, nwAlert
|
||||
|
||||
@@ -40,8 +39,7 @@ class GuiExport(QDialog):
|
||||
self.mainConf = nw.CONFIG
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.optState = ExportLastState(self.theProject,nwFiles.EXPORT_OPT)
|
||||
self.optState.loadSettings()
|
||||
self.optState = self.theProject.optState
|
||||
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.innerBox = QVBoxLayout()
|
||||
@@ -50,8 +48,8 @@ class GuiExport(QDialog):
|
||||
|
||||
self.guiDeco = self.theParent.theTheme.loadDecoration("export",(64,64))
|
||||
|
||||
self.tabMain = GuiExportMain(self.theParent, self.theProject, self.optState)
|
||||
self.tabPandoc = GuiExportPandoc(self.theParent, self.theProject, self.optState)
|
||||
self.tabMain = GuiExportMain(self.theParent, self.theProject)
|
||||
self.tabPandoc = GuiExportPandoc(self.theParent, self.theProject)
|
||||
|
||||
self.tabWidget = QTabWidget()
|
||||
self.tabWidget.addTab(self.tabMain, "Settings")
|
||||
@@ -290,24 +288,24 @@ class GuiExport(QDialog):
|
||||
if saveTo.startswith("~"):
|
||||
saveTo = path.expanduser(saveTo)
|
||||
|
||||
self.optState.setSetting("wNovel", wNovel)
|
||||
self.optState.setSetting("wNotes", wNotes)
|
||||
self.optState.setSetting("eFormat", eFormat)
|
||||
self.optState.setSetting("fixWidth", fixWidth)
|
||||
self.optState.setSetting("wComments",wComments)
|
||||
self.optState.setSetting("wKeywords",wKeywords)
|
||||
self.optState.setSetting("chFormat", chFormat)
|
||||
self.optState.setSetting("unFormat", unFormat)
|
||||
self.optState.setSetting("scFormat", scFormat)
|
||||
self.optState.setSetting("seFormat", seFormat)
|
||||
self.optState.setSetting("saveTo", saveTo)
|
||||
self.optState.setSetting("hScene", hScene)
|
||||
self.optState.setSetting("hSection", hSection)
|
||||
self.optState.setValue("GuiExport", "wNovel", wNovel)
|
||||
self.optState.setValue("GuiExport", "wNotes", wNotes)
|
||||
self.optState.setValue("GuiExport", "eFormat", eFormat)
|
||||
self.optState.setValue("GuiExport", "fixWidth", fixWidth)
|
||||
self.optState.setValue("GuiExport", "wComments", wComments)
|
||||
self.optState.setValue("GuiExport", "wKeywords", wKeywords)
|
||||
self.optState.setValue("GuiExport", "chFormat", chFormat)
|
||||
self.optState.setValue("GuiExport", "unFormat", unFormat)
|
||||
self.optState.setValue("GuiExport", "scFormat", scFormat)
|
||||
self.optState.setValue("GuiExport", "seFormat", seFormat)
|
||||
self.optState.setValue("GuiExport", "saveTo", saveTo)
|
||||
self.optState.setValue("GuiExport", "hScene", hScene)
|
||||
self.optState.setValue("GuiExport", "hSection", hSection)
|
||||
|
||||
# Pandoc Settings
|
||||
pFormat = self.tabPandoc.outputFormat.currentData()
|
||||
|
||||
self.optState.setSetting("pFormat", pFormat)
|
||||
self.optState.setValue("GuiExport", "pFormat", pFormat)
|
||||
|
||||
self.optState.saveSettings()
|
||||
self.close()
|
||||
@@ -362,14 +360,14 @@ class GuiExportMain(QWidget):
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, theParent, theProject, optState):
|
||||
def __init__(self, theParent, theProject):
|
||||
QWidget.__init__(self, theParent)
|
||||
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.theTheme = theParent.theTheme
|
||||
self.outerBox = QGridLayout()
|
||||
self.optState = optState
|
||||
self.optState = self.theProject.optState
|
||||
self.currFormat = self.FMT_TXT
|
||||
|
||||
# Select Files
|
||||
@@ -378,19 +376,27 @@ class GuiExportMain(QWidget):
|
||||
self.guiFiles.setLayout(self.guiFilesForm)
|
||||
|
||||
self.expNovel = QCheckBox("Novel files",self)
|
||||
self.expNovel.setChecked(self.optState.getSetting("wNovel"))
|
||||
self.expNovel.setChecked(
|
||||
self.optState.getBool("GuiExport", "wNovel", True)
|
||||
)
|
||||
self.expNovel.setToolTip("Include all novel files in the exported document")
|
||||
|
||||
self.expNotes = QCheckBox("Note files",self)
|
||||
self.expNotes.setChecked(self.optState.getSetting("wNotes"))
|
||||
self.expNotes.setChecked(
|
||||
self.optState.getBool("GuiExport", "wNotes", False)
|
||||
)
|
||||
self.expNotes.setToolTip("Include all note files in the exported document")
|
||||
|
||||
self.expComments = QCheckBox("Comments",self)
|
||||
self.expComments.setChecked(self.optState.getSetting("wComments"))
|
||||
self.expComments.setChecked(
|
||||
self.optState.getBool("GuiExport", "wComments", False)
|
||||
)
|
||||
self.expComments.setToolTip("Export comments from all files")
|
||||
|
||||
self.expKeywords = QCheckBox("Keywords",self)
|
||||
self.expKeywords.setChecked(self.optState.getSetting("wKeywords"))
|
||||
self.expKeywords.setChecked(
|
||||
self.optState.getBool("GuiExport", "wKeywords", False)
|
||||
)
|
||||
self.expKeywords.setToolTip("Export @keywords from all files")
|
||||
|
||||
self.guiFilesForm.addWidget(self.expNovel, 0, 1)
|
||||
@@ -406,13 +412,17 @@ class GuiExportMain(QWidget):
|
||||
|
||||
self.chapterFormat = QLineEdit()
|
||||
self.chapterFormat.setMaxLength(200)
|
||||
self.chapterFormat.setText(self.optState.getSetting("chFormat"))
|
||||
self.chapterFormat.setText(
|
||||
self.optState.getString("GuiExport", "chFormat", "Chapter %numword%")
|
||||
)
|
||||
self.chapterFormat.setToolTip("Available formats: %num%, %numword%, %title%")
|
||||
self.chapterFormat.setMinimumWidth(250)
|
||||
|
||||
self.unnumFormat = QLineEdit()
|
||||
self.unnumFormat.setMaxLength(200)
|
||||
self.unnumFormat.setText(self.optState.getSetting("unFormat"))
|
||||
self.unnumFormat.setText(
|
||||
self.optState.getString("GuiExport", "unFormat", "%title%")
|
||||
)
|
||||
self.unnumFormat.setToolTip("Available formats: %title%")
|
||||
self.unnumFormat.setMinimumWidth(250)
|
||||
|
||||
@@ -428,22 +438,30 @@ class GuiExportMain(QWidget):
|
||||
|
||||
self.sceneFormat = QLineEdit()
|
||||
self.sceneFormat.setMaxLength(200)
|
||||
self.sceneFormat.setText(self.optState.getSetting("scFormat"))
|
||||
self.sceneFormat.setText(
|
||||
self.optState.getString("GuiExport", "scFormat", "* * *")
|
||||
)
|
||||
self.sceneFormat.setToolTip("Available formats: %title%")
|
||||
self.sceneFormat.setMinimumWidth(100)
|
||||
|
||||
self.sectionFormat = QLineEdit()
|
||||
self.sectionFormat.setMaxLength(200)
|
||||
self.sectionFormat.setText(self.optState.getSetting("seFormat"))
|
||||
self.sectionFormat.setText(
|
||||
self.optState.getString("GuiExport", "seFormat", "")
|
||||
)
|
||||
self.sectionFormat.setToolTip("Available formats: %title%")
|
||||
self.sectionFormat.setMinimumWidth(100)
|
||||
|
||||
self.hideScene = QCheckBox("Skip",self)
|
||||
self.hideScene.setChecked(self.optState.getSetting("hScene"))
|
||||
self.hideScene.setChecked(
|
||||
self.optState.getBool("GuiExport", "hScene", False)
|
||||
)
|
||||
self.hideScene.setToolTip("Skip scene titles in export")
|
||||
|
||||
self.hideSection = QCheckBox("Skip",self)
|
||||
self.hideSection.setChecked(self.optState.getSetting("hSection"))
|
||||
self.hideSection.setChecked(
|
||||
self.optState.getBool("GuiExport", "hSection", False)
|
||||
)
|
||||
self.hideSection.setToolTip("Skip section titles in export")
|
||||
|
||||
self.guiScenesForm.addWidget(QLabel("Scenes"), 0, 0)
|
||||
@@ -458,8 +476,9 @@ class GuiExportMain(QWidget):
|
||||
self.exportToForm = QGridLayout(self)
|
||||
self.exportTo.setLayout(self.exportToForm)
|
||||
|
||||
self.exportPath = QLineEdit(self.optState.getSetting("saveTo"))
|
||||
|
||||
self.exportPath = QLineEdit(
|
||||
self.optState.getString("GuiExport", "saveTo", "")
|
||||
)
|
||||
self.exportGetPath = QPushButton(self.theTheme.getIcon("folder"),"")
|
||||
self.exportGetPath.clicked.connect(self._exportFolder)
|
||||
|
||||
@@ -486,7 +505,9 @@ class GuiExportMain(QWidget):
|
||||
self.outputFormat.addItem("Pandoc via Markdown or HTML", self.FMT_PDOC)
|
||||
self.outputFormat.currentIndexChanged.connect(self._updateFormat)
|
||||
|
||||
optIdx = self.outputFormat.findData(self.optState.getSetting("eFormat"))
|
||||
optIdx = self.outputFormat.findData(
|
||||
self.optState.getInt("GuiExport", "eFormat", 1)
|
||||
)
|
||||
if optIdx == -1:
|
||||
self.outputFormat.setCurrentIndex(1)
|
||||
self._updateFormat(1)
|
||||
@@ -508,7 +529,9 @@ class GuiExportMain(QWidget):
|
||||
self.fixedWidth.setMinimum(0)
|
||||
self.fixedWidth.setMaximum(999)
|
||||
self.fixedWidth.setSingleStep(1)
|
||||
self.fixedWidth.setValue(self.optState.getSetting("fixWidth"))
|
||||
self.fixedWidth.setValue(
|
||||
self.optState.getInt("GuiExport", "fixWidth", 80)
|
||||
)
|
||||
self.fixedWidth.setToolTip(
|
||||
"Applies to .txt and .md files. A value of '0' disables the feature."
|
||||
)
|
||||
@@ -609,13 +632,13 @@ class GuiExportPandoc(QWidget):
|
||||
FMT_ZIM : "markdown",
|
||||
}
|
||||
|
||||
def __init__(self, theParent, theProject, optState):
|
||||
def __init__(self, theParent, theProject):
|
||||
QWidget.__init__(self, theParent)
|
||||
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.outerBox = QGridLayout()
|
||||
self.optState = optState
|
||||
self.optState = self.theProject.optState
|
||||
|
||||
try:
|
||||
import pypandoc
|
||||
@@ -659,7 +682,9 @@ class GuiExportPandoc(QWidget):
|
||||
self.outputFormat.addItem("ePUB eBook v3 (.epub3)", self.FMT_EPUB3)
|
||||
self.outputFormat.addItem("Zim Wiki (.txt)", self.FMT_ZIM)
|
||||
|
||||
optIdx = self.outputFormat.findData(self.optState.getSetting("pFormat"))
|
||||
optIdx = self.outputFormat.findData(
|
||||
self.optState.getInt("GuiExport", "pFormat", 1)
|
||||
)
|
||||
if optIdx == -1:
|
||||
self.outputFormat.setCurrentIndex(1)
|
||||
else:
|
||||
@@ -678,30 +703,3 @@ class GuiExportPandoc(QWidget):
|
||||
return
|
||||
|
||||
# END Class GuiExportPandoc
|
||||
|
||||
class ExportLastState(OptLastState):
|
||||
|
||||
def __init__(self, theProject, theFile):
|
||||
OptLastState.__init__(self, theProject, theFile)
|
||||
self.theState = {
|
||||
"wNovel" : True,
|
||||
"wNotes" : False,
|
||||
"eFormat" : 1,
|
||||
"pFormat" : 1,
|
||||
"fixWidth" : 80,
|
||||
"wComments" : False,
|
||||
"wKeywords" : False,
|
||||
"chFormat" : "Chapter %numword%",
|
||||
"unFormat" : "%title%",
|
||||
"scFormat" : "* * *",
|
||||
"seFormat" : "",
|
||||
"saveTo" : "",
|
||||
"hScene" : False,
|
||||
"hSection" : False,
|
||||
}
|
||||
self.stringOpt = ("chFormat","unFormat","scFormat","seFormat","saveTo")
|
||||
self.boolOpt = ("wNovel","wNotes","wComments","wKeywords","hScene","hSection")
|
||||
self.intOpt = ("eFormat","pFormat","fixWidth")
|
||||
return
|
||||
|
||||
# END Class ExportLastState
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
import logging
|
||||
import nw
|
||||
|
||||
from os import path
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QVBoxLayout, QGroupBox, QFormLayout, QLineEdit,
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
import logging
|
||||
import nw
|
||||
|
||||
from os import path
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QIcon, QPixmap, QColor, QBrush
|
||||
from PyQt5.QtWidgets import (
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""novelWriter GUI Open Project
|
||||
|
||||
novelWriter – GUI Open Project
|
||||
================================
|
||||
New and open project dialog
|
||||
|
||||
File History:
|
||||
Created: 2020-02-26 [0.4.5]
|
||||
|
||||
"""
|
||||
|
||||
import logging
|
||||
import nw
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QHBoxLayout, QVBoxLayout, QGridLayout, QPushButton, QTreeWidget,
|
||||
QAbstractItemView, QTreeWidgetItem
|
||||
)
|
||||
|
||||
from nw.common import formatInt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GuiProjectLoad(QDialog):
|
||||
|
||||
def __init__(self, theParent):
|
||||
QDialog.__init__(self, theParent)
|
||||
|
||||
logger.debug("Initialising GuiProjectLoad ...")
|
||||
|
||||
self.mainConf = nw.CONFIG
|
||||
self.theParent = theParent
|
||||
self.sourceItem = None
|
||||
self.openPath = None
|
||||
|
||||
self.outerBox = QHBoxLayout()
|
||||
self.innerBox = QVBoxLayout()
|
||||
self.setWindowTitle("Open Project")
|
||||
self.setLayout(self.outerBox)
|
||||
|
||||
self.guiDeco = self.theParent.theTheme.loadDecoration("nwicon", (128, 128))
|
||||
|
||||
self.outerBox.addWidget(self.guiDeco, 0, Qt.AlignTop)
|
||||
self.outerBox.addLayout(self.innerBox)
|
||||
|
||||
self.projectForm = QGridLayout()
|
||||
self.projectForm.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.listBox = QTreeWidget()
|
||||
self.listBox.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.listBox.setDragDropMode(QAbstractItemView.NoDragDrop)
|
||||
self.listBox.setColumnCount(4)
|
||||
self.listBox.setHeaderLabels(["Working Title","Words","Accessed","Path"])
|
||||
self.listBox.setRootIsDecorated(False)
|
||||
|
||||
treeHead = self.listBox.headerItem()
|
||||
treeHead.setTextAlignment(1, Qt.AlignRight)
|
||||
|
||||
self.recentButton = QPushButton("Open")
|
||||
self.recentButton.clicked.connect(self._doOpenRecent)
|
||||
self.browseButton = QPushButton("Browse")
|
||||
self.browseButton.clicked.connect(self._doBrowse)
|
||||
self.closeButton = QPushButton("Close")
|
||||
self.closeButton.clicked.connect(self._doClose)
|
||||
|
||||
self.projectForm.addWidget(self.listBox, 0, 0, 1, 4)
|
||||
self.projectForm.addWidget(self.recentButton, 1, 1)
|
||||
self.projectForm.addWidget(self.browseButton, 1, 2)
|
||||
self.projectForm.addWidget(self.closeButton, 1, 3)
|
||||
self.projectForm.setColumnStretch(0, 1)
|
||||
|
||||
self.innerBox.addLayout(self.projectForm)
|
||||
|
||||
self.rejected.connect(self._doClose)
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(750)
|
||||
self.setMinimumHeight(450)
|
||||
self.show()
|
||||
|
||||
self._populateList()
|
||||
|
||||
logger.debug("GuiProjectLoad initialisation complete")
|
||||
|
||||
return
|
||||
|
||||
##
|
||||
# Buttons
|
||||
##
|
||||
|
||||
def _doOpenRecent(self):
|
||||
"""Close the dialog window with a recent project selected.
|
||||
"""
|
||||
logger.verbose("GuiProjectLoad open button clicked")
|
||||
|
||||
selItems = self.listBox.selectedItems()
|
||||
if selItems:
|
||||
self.openPath = selItems[0].text(3)
|
||||
self.accept()
|
||||
else:
|
||||
self.openPath = None
|
||||
|
||||
return
|
||||
|
||||
def _doBrowse(self):
|
||||
"""Close the dialog window with no selected path, triggering the
|
||||
project browser dialog.
|
||||
"""
|
||||
logger.verbose("GuiProjectLoad browse button clicked")
|
||||
self.openPath = None
|
||||
self.accept()
|
||||
return
|
||||
|
||||
def _doClose(self):
|
||||
"""Close the dialog window without doing anything.
|
||||
"""
|
||||
logger.verbose("GuiProjectLoad close button clicked")
|
||||
self.close()
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
def _populateList(self):
|
||||
"""Populate the list box with recent project data.
|
||||
"""
|
||||
|
||||
listOrder = []
|
||||
listData = {}
|
||||
for projPath in self.mainConf.recentProj.keys():
|
||||
theEntry = self.mainConf.recentProj[projPath]
|
||||
theTitle = ""
|
||||
theTime = 0
|
||||
theWords = 0
|
||||
if "title" in theEntry.keys():
|
||||
theTitle = theEntry["title"]
|
||||
if "time" in theEntry.keys():
|
||||
theTime = theEntry["time"]
|
||||
if "words" in theEntry.keys():
|
||||
theWords = theEntry["words"]
|
||||
if theTime > 0:
|
||||
listOrder.append(theTime)
|
||||
listData[theTime] = [theTitle, theWords, projPath]
|
||||
|
||||
self.listBox.clear()
|
||||
hasSelection = False
|
||||
for timeStamp in sorted(listOrder, reverse=True):
|
||||
newItem = QTreeWidgetItem([""]*4)
|
||||
newItem.setText(0, listData[timeStamp][0])
|
||||
newItem.setText(1, formatInt(listData[timeStamp][1]))
|
||||
newItem.setText(2, datetime.fromtimestamp(timeStamp).strftime("%x %X"))
|
||||
newItem.setText(3, listData[timeStamp][2])
|
||||
newItem.setTextAlignment(1, Qt.AlignRight)
|
||||
self.listBox.addTopLevelItem(newItem)
|
||||
if not hasSelection:
|
||||
newItem.setSelected(True)
|
||||
hasSelection = True
|
||||
|
||||
self.listBox.resizeColumnToContents(0)
|
||||
self.listBox.resizeColumnToContents(1)
|
||||
self.listBox.resizeColumnToContents(2)
|
||||
|
||||
return
|
||||
|
||||
# END Class GuiProjectLoad
|
||||
@@ -24,7 +24,6 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from nw.constants import nwConst, nwFiles, nwAlert
|
||||
from nw.tools import OptLastState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,8 +37,7 @@ class GuiSessionLogView(QDialog):
|
||||
self.mainConf = nw.CONFIG
|
||||
self.theProject = theProject
|
||||
self.theParent = theParent
|
||||
self.optState = SessionLogLastState(self.theProject,nwFiles.SLOG_OPT)
|
||||
self.optState.loadSettings()
|
||||
self.optState = self.theProject.optState
|
||||
|
||||
self.timeFilter = 0.0
|
||||
self.timeTotal = 0.0
|
||||
@@ -52,13 +50,13 @@ class GuiSessionLogView(QDialog):
|
||||
self.setMinimumHeight(400)
|
||||
|
||||
widthCol0 = self.optState.validIntRange(
|
||||
self.optState.getSetting("widthCol0"), 30, 999, 180
|
||||
self.optState.getInt("GuiSession", "widthCol0", 180), 30, 999, 180
|
||||
)
|
||||
widthCol1 = self.optState.validIntRange(
|
||||
self.optState.getSetting("widthCol1"), 30, 999, 80
|
||||
self.optState.getInt("GuiSession", "widthCol1", 80), 30, 999, 80
|
||||
)
|
||||
widthCol2 = self.optState.validIntRange(
|
||||
self.optState.getSetting("widthCol2"), 30, 999, 80
|
||||
self.optState.getInt("GuiSession", "widthCol2", 80), 30, 999, 80
|
||||
)
|
||||
|
||||
self.listBox = QTreeWidget()
|
||||
@@ -77,10 +75,11 @@ class GuiSessionLogView(QDialog):
|
||||
|
||||
sortValid = (Qt.AscendingOrder, Qt.DescendingOrder)
|
||||
sortCol = self.optState.validIntRange(
|
||||
self.optState.getSetting("sortCol"), 0, 2, 0
|
||||
self.optState.getInt("GuiSession", "sortCol", 0), 0, 2, 0
|
||||
)
|
||||
sortOrder = self.optState.validIntTuple(
|
||||
self.optState.getSetting("sortOrder"), sortValid, Qt.DescendingOrder
|
||||
self.optState.getInt("GuiSession", "sortOrder", Qt.DescendingOrder),
|
||||
sortValid, Qt.DescendingOrder
|
||||
)
|
||||
|
||||
self.listBox.sortByColumn(sortCol, sortOrder)
|
||||
@@ -110,11 +109,15 @@ class GuiSessionLogView(QDialog):
|
||||
self.filterBox.setLayout(self.filterBoxForm)
|
||||
|
||||
self.hideZeros = QCheckBox("Hide zero word count", self)
|
||||
self.hideZeros.setChecked(self.optState.getSetting("hideZeros"))
|
||||
self.hideZeros.setChecked(
|
||||
self.optState.getBool("GuiSession", "hideZeros", True)
|
||||
)
|
||||
self.hideZeros.stateChanged.connect(self._doHideZeros)
|
||||
|
||||
self.hideNegative = QCheckBox("Hide negative word count", self)
|
||||
self.hideNegative.setChecked(self.optState.getSetting("hideNegative"))
|
||||
self.hideNegative.setChecked(
|
||||
self.optState.getBool("GuiSession", "hideNegative", False)
|
||||
)
|
||||
self.hideNegative.stateChanged.connect(self._doHideNegative)
|
||||
|
||||
self.filterBoxForm.addWidget(self.hideZeros, 0, 0)
|
||||
@@ -208,13 +211,13 @@ class GuiSessionLogView(QDialog):
|
||||
hideZeros = self.hideZeros.isChecked()
|
||||
hideNegative = self.hideNegative.isChecked()
|
||||
|
||||
self.optState.setSetting("widthCol0", widthCol0)
|
||||
self.optState.setSetting("widthCol1", widthCol1)
|
||||
self.optState.setSetting("widthCol2", widthCol2)
|
||||
self.optState.setSetting("sortCol", sortCol)
|
||||
self.optState.setSetting("sortOrder", sortOrder)
|
||||
self.optState.setSetting("hideZeros", hideZeros)
|
||||
self.optState.setSetting("hideNegative",hideNegative)
|
||||
self.optState.setValue("GuiSession", "widthCol0", widthCol0)
|
||||
self.optState.setValue("GuiSession", "widthCol1", widthCol1)
|
||||
self.optState.setValue("GuiSession", "widthCol2", widthCol2)
|
||||
self.optState.setValue("GuiSession", "sortCol", sortCol)
|
||||
self.optState.setValue("GuiSession", "sortOrder", sortOrder)
|
||||
self.optState.setValue("GuiSession", "hideZeros", hideZeros)
|
||||
self.optState.setValue("GuiSession", "hideNegative", hideNegative)
|
||||
|
||||
self.optState.saveSettings()
|
||||
self.close()
|
||||
@@ -237,23 +240,3 @@ class GuiSessionLogView(QDialog):
|
||||
return "%02d:%02d:%02d" % (tH,tM,tS)
|
||||
|
||||
# END Class GuiSessionLogView
|
||||
|
||||
class SessionLogLastState(OptLastState):
|
||||
|
||||
def __init__(self, theProject, theFile):
|
||||
OptLastState.__init__(self, theProject, theFile)
|
||||
self.theState = {
|
||||
"widthCol0" : 180,
|
||||
"widthCol1" : 80,
|
||||
"widthCol2" : 80,
|
||||
"sortCol" : 0,
|
||||
"sortOrder" : Qt.DescendingOrder,
|
||||
"hideZeros" : True,
|
||||
"hideNegative" : False,
|
||||
}
|
||||
self.stringOpt = ()
|
||||
self.boolOpt = ("hideZeros","hideNegative")
|
||||
self.intOpt = ("widthCol0","widthCol1","widthCol2","sortCol","sortOrder")
|
||||
return
|
||||
|
||||
# END Class SessionLogLastState
|
||||
|
||||
@@ -23,11 +23,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
class GuiDocDetails(QFrame):
|
||||
|
||||
C_NAME = 0
|
||||
C_COUNT = 1
|
||||
C_FLAGS = 2
|
||||
C_HANDLE = 3
|
||||
|
||||
def __init__(self, theParent, theProject):
|
||||
QFrame.__init__(self, theParent)
|
||||
|
||||
|
||||
@@ -21,13 +21,13 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
from PyQt5.QtGui import (
|
||||
QTextCursor, QTextOption, QKeySequence, QFont, QColor, QPalette,
|
||||
QTextDocument
|
||||
QTextDocument, QCursor
|
||||
)
|
||||
|
||||
from nw.project import NWDoc
|
||||
from nw.gui.tools import GuiDocHighlighter, WordCounter
|
||||
from nw.tools import NWSpellCheck, NWSpellSimple
|
||||
from nw.constants import nwFiles, nwUnicode, nwDocAction, nwAlert
|
||||
from nw.tools import NWSpellSimple
|
||||
from nw.constants import nwUnicode, nwDocAction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -209,7 +209,7 @@ class GuiDocEditor(QTextEdit):
|
||||
risk overwriting the file if it exists. This can for instance
|
||||
happen of the file contains binary elements or an encoding that
|
||||
novelWriter does not support. If load is successful, or the
|
||||
document is new (empty string) we set up the editor for editing
|
||||
document is new (empty string), we set up the editor for editing
|
||||
the file.
|
||||
"""
|
||||
|
||||
@@ -219,6 +219,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.clearEditor()
|
||||
return False
|
||||
|
||||
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
|
||||
self.hLight.setHandle(tHandle)
|
||||
|
||||
# Check that the document is not too big for full, initial spell
|
||||
@@ -246,6 +247,7 @@ class GuiDocEditor(QTextEdit):
|
||||
self.theParent.noticeBar.showNote("This document is read only.")
|
||||
|
||||
self.hLight.spellCheck = spTemp
|
||||
qApp.restoreOverrideCursor()
|
||||
|
||||
return True
|
||||
|
||||
@@ -277,8 +279,9 @@ class GuiDocEditor(QTextEdit):
|
||||
return True
|
||||
|
||||
def updateDocMargins(self):
|
||||
"""Automatically adjust the margins so the text is centred, but
|
||||
only if Config.textFixedW is enabled or we're in Zen mode.
|
||||
"""Automatically adjust the margins so the text is centred if
|
||||
Config.textFixedW is enabled or we're in Zen mode. Otherwise,
|
||||
just ensure the margins are set correctly.
|
||||
"""
|
||||
|
||||
if self.mainConf.textFixedW or self.theParent.isZenMode:
|
||||
@@ -335,7 +338,7 @@ class GuiDocEditor(QTextEdit):
|
||||
return theText
|
||||
|
||||
def setCursorPosition(self, thePosition):
|
||||
if thePosition > 0:
|
||||
if thePosition >= 0:
|
||||
theCursor = self.textCursor()
|
||||
theCursor.setPosition(thePosition)
|
||||
self.setTextCursor(theCursor)
|
||||
@@ -361,8 +364,8 @@ class GuiDocEditor(QTextEdit):
|
||||
def setSpellCheck(self, theMode):
|
||||
"""This is the master spell check setting function, and this one
|
||||
should call all other setSpellCheck functions in other classes.
|
||||
If the spell check mode is not defined, then toggle the current
|
||||
status saved in the class.
|
||||
If the spell check mode (theMode) is not defined (None), then
|
||||
toggle the current status saved in this class.
|
||||
"""
|
||||
|
||||
if theMode is None:
|
||||
@@ -375,23 +378,29 @@ class GuiDocEditor(QTextEdit):
|
||||
self.theParent.mainMenu.setSpellCheck(theMode)
|
||||
self.theProject.setSpellCheck(theMode)
|
||||
self.hLight.setSpellCheck(theMode)
|
||||
self.reHighlightDocument()
|
||||
if not self.bigDoc:
|
||||
self.spellCheckDocument()
|
||||
|
||||
logger.verbose("Spell check is set to %s" % str(theMode))
|
||||
|
||||
return True
|
||||
|
||||
def reHighlightDocument(self):
|
||||
def spellCheckDocument(self):
|
||||
"""Rerun the highlighter to update spell checking status of the
|
||||
currently loaded text. The fastest way to do this, at least as
|
||||
of Qt 5.13, is to clear the text and put it back.
|
||||
"""
|
||||
|
||||
logger.verbose("Running spell checker")
|
||||
if self.spellCheck:
|
||||
theText = self.getText()
|
||||
self.clear()
|
||||
bfTime = time()
|
||||
self.setPlainText(theText)
|
||||
qApp.setOverrideCursor(QCursor(Qt.WaitCursor))
|
||||
if self.bigDoc:
|
||||
theText = self.getText()
|
||||
self.setPlainText(theText)
|
||||
else:
|
||||
self.hLight.rehighlight()
|
||||
qApp.restoreOverrideCursor()
|
||||
afTime = time()
|
||||
logger.debug("Document re-highlighted in %.3f milliseconds" % (1000*(afTime-bfTime)))
|
||||
|
||||
|
||||
+120
-19
@@ -16,10 +16,10 @@ import nw
|
||||
from PyQt5.QtCore import Qt, QSize
|
||||
from PyQt5.QtGui import QFont, QColor
|
||||
from PyQt5.QtWidgets import (
|
||||
QTreeWidget, QTreeWidgetItem, QAbstractItemView, QApplication
|
||||
QTreeWidget, QTreeWidgetItem, QAbstractItemView, QApplication, QMessageBox
|
||||
)
|
||||
|
||||
from nw.project import NWItem
|
||||
from nw.project import NWItem, NWDoc
|
||||
from nw.constants import (
|
||||
nwLabels, nwItemType, nwItemClass, nwItemLayout, nwAlert
|
||||
)
|
||||
@@ -171,14 +171,21 @@ class GuiDocTree(QTreeWidget):
|
||||
return False
|
||||
|
||||
# Add the new item to the tree
|
||||
nwItem = self.theProject.getItem(tHandle)
|
||||
trItem = self._addTreeItem(nwItem)
|
||||
self.revealTreeItem(tHandle)
|
||||
self.theParent.editItem()
|
||||
|
||||
return True
|
||||
|
||||
def revealTreeItem(self, tHandle):
|
||||
"""Reveal a newly added project item in the project tree.
|
||||
"""
|
||||
nwItem = self.theProject.getItem(tHandle)
|
||||
trItem = self._addTreeItem(nwItem)
|
||||
pHandle = nwItem.parHandle
|
||||
if pHandle is not None and pHandle in self.theMap.keys():
|
||||
self.theMap[pHandle].setExpanded(True)
|
||||
self.clearSelection()
|
||||
trItem.setSelected(True)
|
||||
self.theParent.editItem()
|
||||
|
||||
return True
|
||||
|
||||
def moveTreeItem(self, nStep):
|
||||
@@ -221,6 +228,16 @@ class GuiDocTree(QTreeWidget):
|
||||
self.theProject.setTreeOrder(theList)
|
||||
return True
|
||||
|
||||
def getTreeFromHandle(self, tHandle):
|
||||
"""Recursively return all the children items starting from a
|
||||
given item handle.
|
||||
"""
|
||||
theList = []
|
||||
theItem = self._getTreeItem(tHandle)
|
||||
if theItem is not None:
|
||||
theList = self._scanChildren(theList, theItem, 0)
|
||||
return theList
|
||||
|
||||
def getColumnSizes(self):
|
||||
retVals = [
|
||||
self.columnWidth(0),
|
||||
@@ -229,7 +246,44 @@ class GuiDocTree(QTreeWidget):
|
||||
]
|
||||
return retVals
|
||||
|
||||
def deleteItem(self, tHandle=None):
|
||||
def emptyTrash(self):
|
||||
"""Permanently delete all documents in the Trash folder. This
|
||||
function only asks for confirmation once, and calls the regular
|
||||
deleteItem function for each document in the Trash folder.
|
||||
"""
|
||||
|
||||
logger.debug("Emptying Trash folder")
|
||||
if self.theProject.trashRoot is None:
|
||||
self.makeAlert("There is no Trash folder.", nwAlert.INFO)
|
||||
return False
|
||||
|
||||
theTrash = self.getTreeFromHandle(self.theProject.trashRoot)
|
||||
if self.theProject.trashRoot in theTrash:
|
||||
theTrash.remove(self.theProject.trashRoot)
|
||||
|
||||
nTrash = len(theTrash)
|
||||
if nTrash == 0:
|
||||
self.makeAlert("The Trash folder is empty.", nwAlert.INFO)
|
||||
return False
|
||||
|
||||
msgBox = QMessageBox()
|
||||
msgRes = msgBox.question(
|
||||
self, "Empty Trash", "Permanently delete %d file%s from Trash?" % (
|
||||
nTrash, "s"*int(nTrash > 1)
|
||||
)
|
||||
)
|
||||
if msgRes != QMessageBox.Yes:
|
||||
return False
|
||||
|
||||
logger.verbose("Deleting %d files from Trash" % nTrash)
|
||||
for tHandle in self.getTreeFromHandle(self.theProject.trashRoot):
|
||||
if tHandle == self.theProject.trashRoot:
|
||||
continue
|
||||
self.deleteItem(tHandle, True)
|
||||
|
||||
return True
|
||||
|
||||
def deleteItem(self, tHandle=None, alreadyAsked=False):
|
||||
"""Delete items from the tree. Note that this does not delete
|
||||
the item from the item tree in the project object. However,
|
||||
since this is only meta data, there isn't really a need to do
|
||||
@@ -246,21 +300,61 @@ class GuiDocTree(QTreeWidget):
|
||||
trItemS = self._getTreeItem(tHandle)
|
||||
nwItemS = self.theProject.getItem(tHandle)
|
||||
|
||||
if nwItemS is None:
|
||||
return False
|
||||
|
||||
if nwItemS.itemType == nwItemType.FILE:
|
||||
logger.debug("User requested file %s moved to trash" % tHandle)
|
||||
trItemP = trItemS.parent()
|
||||
trItemT = self._addTrashRoot()
|
||||
if trItemP is None or trItemT is None:
|
||||
logger.error("Could not move item to trash")
|
||||
logger.error("Could not delete item")
|
||||
return False
|
||||
tIndex = trItemP.indexOfChild(trItemS)
|
||||
trItemC = trItemP.takeChild(tIndex)
|
||||
trItemT.addChild(trItemC)
|
||||
nwItemS.setParent(self.theProject.trashRoot)
|
||||
self.clearSelection()
|
||||
trItemP.setSelected(True)
|
||||
self.theProject.setProjectChanged(True)
|
||||
self.theParent.theIndex.deleteHandle(tHandle)
|
||||
|
||||
pHandle = nwItemS.parHandle
|
||||
if pHandle is not None and pHandle == self.theProject.trashRoot:
|
||||
# If the file is in the trash folder already, as the
|
||||
# user if they want to permanently delete the file.
|
||||
|
||||
doPermanent = False
|
||||
if self.mainConf.showGUI and not alreadyAsked:
|
||||
msgBox = QMessageBox()
|
||||
msgRes = msgBox.question(
|
||||
self, "Delete File", "Permanently delete file '%s'?" % nwItemS.itemName
|
||||
)
|
||||
if msgRes == QMessageBox.Yes:
|
||||
doPermanent = True
|
||||
else:
|
||||
doPermanent = True
|
||||
|
||||
if doPermanent:
|
||||
logger.debug("Permanently deleting file with handle %s" % tHandle)
|
||||
|
||||
tIndex = trItemP.indexOfChild(trItemS)
|
||||
trItemC = trItemP.takeChild(tIndex)
|
||||
|
||||
if self.theParent.docEditor.theHandle == tHandle:
|
||||
self.theParent.closeDocument()
|
||||
|
||||
theDoc = NWDoc(self.theProject, self.theParent)
|
||||
theDoc.deleteDocument(tHandle)
|
||||
self.theProject.deleteItem(tHandle)
|
||||
self.theParent.theIndex.deleteHandle(tHandle)
|
||||
|
||||
else:
|
||||
# The file is not already in the trash folder, so we
|
||||
# move it there.
|
||||
|
||||
if pHandle is None:
|
||||
logger.warning("File has no parent item")
|
||||
|
||||
tIndex = trItemP.indexOfChild(trItemS)
|
||||
trItemC = trItemP.takeChild(tIndex)
|
||||
trItemT.addChild(trItemC)
|
||||
nwItemS.setParent(self.theProject.trashRoot)
|
||||
|
||||
self.theProject.setProjectChanged(True)
|
||||
self.theParent.theIndex.deleteHandle(tHandle)
|
||||
|
||||
elif nwItemS.itemType == nwItemType.FOLDER:
|
||||
logger.debug("User requested folder %s deleted" % tHandle)
|
||||
@@ -271,8 +365,6 @@ class GuiDocTree(QTreeWidget):
|
||||
tIndex = trItemP.indexOfChild(trItemS)
|
||||
if trItemS.childCount() == 0:
|
||||
trItemP.takeChild(tIndex)
|
||||
self.clearSelection()
|
||||
trItemP.setSelected(True)
|
||||
self.theProject.deleteItem(tHandle)
|
||||
else:
|
||||
self.makeAlert(["Cannot delete folder.","It is not empty."], nwAlert.ERROR)
|
||||
@@ -428,6 +520,9 @@ class GuiDocTree(QTreeWidget):
|
||||
return newItem
|
||||
|
||||
def _addTrashRoot(self):
|
||||
"""Adds the trash root folder if it doesn't already exist in the
|
||||
project tree.
|
||||
"""
|
||||
if self.theProject.trashRoot is None:
|
||||
self.theProject.addTrash()
|
||||
trItem = self._addTreeItem(
|
||||
@@ -518,16 +613,22 @@ class GuiDocTree(QTreeWidget):
|
||||
"""
|
||||
sHandle = self.getSelectedHandle()
|
||||
if sHandle is None:
|
||||
logger.error("No handle selected")
|
||||
return
|
||||
|
||||
dIndex = self.indexAt(theEvent.pos())
|
||||
dIndex = self.indexAt(theEvent.pos())
|
||||
if not dIndex.isValid():
|
||||
logger.error("Invalid drop index")
|
||||
return
|
||||
|
||||
dItem = self.itemFromIndex(dIndex)
|
||||
dHandle = dItem.text(self.C_HANDLE)
|
||||
snItem = self.theProject.getItem(sHandle)
|
||||
dnItem = self.theProject.getItem(dHandle)
|
||||
if dnItem is None:
|
||||
self.makeAlert("The item cannot be moved to that location.", nwAlert.ERROR)
|
||||
return
|
||||
|
||||
isSame = snItem.itemClass == dnItem.itemClass
|
||||
isNone = snItem.itemClass == nwItemClass.NO_CLASS
|
||||
isNote = snItem.itemLayout == nwItemLayout.NOTE
|
||||
|
||||
@@ -21,7 +21,6 @@ from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem
|
||||
)
|
||||
|
||||
from nw.tools import OptLastState
|
||||
from nw.constants import nwItemLayout, nwKeyWords, nwLabels, nwFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -75,7 +74,7 @@ class GuiProjectOutline(QWidget):
|
||||
self.theParent = theParent
|
||||
self.theProject = theProject
|
||||
self.theIndex = self.theParent.theIndex
|
||||
self.optState = OutlineLastState(self.theProject,nwFiles.OUTLINE_OPT)
|
||||
self.optState = self.theProject.optState
|
||||
|
||||
self.showWords = True
|
||||
self.showSynopsis = True
|
||||
@@ -111,13 +110,13 @@ class GuiProjectOutline(QWidget):
|
||||
colW.append(self.mainTree.columnWidth(iCol))
|
||||
|
||||
self.treeCols["width"] = colW
|
||||
self.optState.setSetting("headState", self.treeCols)
|
||||
self.optState.setValue("GuiProjectOutline", "headState", self.treeCols)
|
||||
self.optState.saveSettings()
|
||||
return
|
||||
|
||||
def loadHeaderState(self):
|
||||
self.optState.loadSettings()
|
||||
treeCols = self.optState.getSetting("headState")
|
||||
|
||||
treeCols = self.optState.getValue("GuiProjectOutline", "headState", {})
|
||||
|
||||
if "order" not in treeCols.keys(): return
|
||||
if not isinstance(treeCols["order"], list): return
|
||||
@@ -253,15 +252,3 @@ class GuiProjectOutline(QWidget):
|
||||
return
|
||||
|
||||
# END Class GuiProjectOutline
|
||||
|
||||
class OutlineLastState(OptLastState):
|
||||
|
||||
def __init__(self, theProject, theFile):
|
||||
OptLastState.__init__(self, theProject, theFile)
|
||||
self.theState = {
|
||||
"headState" : {},
|
||||
}
|
||||
self.dictOpt = ("headState")
|
||||
return
|
||||
|
||||
# END Class OutlineLastState
|
||||
|
||||
@@ -86,13 +86,13 @@ class GuiSearchBar(QFrame):
|
||||
if not self.isVisible():
|
||||
self.setVisible(True)
|
||||
self.searchBox.setText(theText)
|
||||
self.searchBox.setFocus(True)
|
||||
self.searchBox.setFocus()
|
||||
logger.verbose("Setting search text to '%s'" % theText)
|
||||
return True
|
||||
|
||||
def setReplaceText(self, theText):
|
||||
self._replaceVisible(True)
|
||||
self.replaceBox.setFocus(True)
|
||||
self.replaceBox.setFocus()
|
||||
self.replaceBox.setText(theText)
|
||||
return True
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ from PyQt5.QtWidgets import (
|
||||
QWidget, QLabel, QScrollArea, QFrame, QToolButton, QCheckBox, QGridLayout
|
||||
)
|
||||
|
||||
from nw.constants import nwLabels
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GuiDocViewDetails(QWidget):
|
||||
|
||||
+27
-3
@@ -45,8 +45,11 @@ class GuiIcons:
|
||||
}
|
||||
|
||||
DECO_MAP = {
|
||||
"export" : "export.svg",
|
||||
"settings" : "gear.svg",
|
||||
"nwicon" : ["icons", "novelWriter.svg"],
|
||||
"export" : ["graphics", "export.svg"],
|
||||
"merge" : ["graphics", "merge.svg"],
|
||||
"settings" : ["graphics", "gear.svg"],
|
||||
"split" : ["graphics", "split.svg"],
|
||||
}
|
||||
|
||||
def __init__(self, theParent):
|
||||
@@ -65,6 +68,9 @@ class GuiIcons:
|
||||
return
|
||||
|
||||
def initIcons(self, priPath):
|
||||
"""Load all icons listed in the icon map. Can be overridden by
|
||||
the selected theme.
|
||||
"""
|
||||
|
||||
self.priPath = priPath
|
||||
self.secPath = self.mainConf.iconPath
|
||||
@@ -76,12 +82,19 @@ class GuiIcons:
|
||||
return
|
||||
|
||||
def loadDecoration(self, decoKey, decoSize=None):
|
||||
"""Load graphical decoration element based on the decoration
|
||||
map. This function always returns a QSwgWidget.
|
||||
"""
|
||||
|
||||
if decoKey not in self.DECO_MAP:
|
||||
logger.error("Decoration with name '%s' does not exist" % decoKey)
|
||||
return QSvgWidget()
|
||||
|
||||
svgPath = path.join(self.mainConf.graphPath, self.DECO_MAP[decoKey])
|
||||
svgPath = path.join(
|
||||
self.mainConf.assetPath,
|
||||
self.DECO_MAP[decoKey][0],
|
||||
self.DECO_MAP[decoKey][1]
|
||||
)
|
||||
if not path.isfile(svgPath):
|
||||
logger.error("Decoration file '%s' not in assets folder" % self.DECO_MAP[decoKey])
|
||||
return QSvgWidget()
|
||||
@@ -93,11 +106,17 @@ class GuiIcons:
|
||||
return svgDeco
|
||||
|
||||
def getIcon(self, iconKey, iconSize=None):
|
||||
"""Return an icon from the icon buffer. If it doesn't exist,
|
||||
return an empty icon.
|
||||
"""
|
||||
if iconKey in self.qIcons:
|
||||
return self.qIcons[iconKey]
|
||||
return QIcon()
|
||||
|
||||
def getPixmap(self, iconKey, iconSize):
|
||||
"""Return an icon from the icon buffer as a QPixmap. If it
|
||||
doesn't exist, return an empty QPixmap.
|
||||
"""
|
||||
if iconKey in self.qIcons:
|
||||
return self.qIcons[iconKey].pixmap(iconSize[0], iconSize[1], QIcon.Normal)
|
||||
return QPixmap()
|
||||
@@ -107,6 +126,11 @@ class GuiIcons:
|
||||
##
|
||||
|
||||
def _loadIcon(self, iconKey):
|
||||
"""Load an icon from the assets or theme folder, with a
|
||||
preference for dark/light icons depending on theme type, if such
|
||||
an icon exists. Prefer svg files over png files. Always returns
|
||||
a QIcon.
|
||||
"""
|
||||
|
||||
if iconKey not in self.ICON_MAP:
|
||||
logger.error("Icon with name '%s' does not exist" % iconKey)
|
||||
|
||||
+21
-41
@@ -48,11 +48,6 @@ class GuiMainMenu(QMenuBar):
|
||||
|
||||
return
|
||||
|
||||
def openRecentProject(self, menuItem, recentItem):
|
||||
logger.verbose("User requested opening recent project #%d" % recentItem)
|
||||
self.theParent.openProject(self.mainConf.recentList[recentItem])
|
||||
return True
|
||||
|
||||
def setAvailableRoot(self):
|
||||
for itemClass in nwItemClass:
|
||||
if itemClass == nwItemClass.NO_CLASS: continue
|
||||
@@ -66,30 +61,6 @@ class GuiMainMenu(QMenuBar):
|
||||
# Update Menu on Settings Changed
|
||||
##
|
||||
|
||||
def updateMenu(self):
|
||||
self.updateRecentProjects()
|
||||
return
|
||||
|
||||
def updateRecentProjects(self):
|
||||
|
||||
self.recentMenu.clear()
|
||||
for n in range(len(self.mainConf.recentList)):
|
||||
recentProject = self.mainConf.recentList[n]
|
||||
if recentProject == "": continue
|
||||
menuItem = QAction("%s" % recentProject, self.projMenu)
|
||||
menuItem.triggered.connect(
|
||||
lambda menuItem, n=n : self.openRecentProject(menuItem, n)
|
||||
)
|
||||
self.recentMenu.addAction(menuItem)
|
||||
|
||||
self.recentMenu.addSeparator()
|
||||
menuItem = QAction("Clear Recent Projects", self)
|
||||
menuItem.setStatusTip("Clear the list of recent projects")
|
||||
menuItem.triggered.connect(self._clearRecentProjects)
|
||||
self.recentMenu.addAction(menuItem)
|
||||
|
||||
return
|
||||
|
||||
def setSpellCheck(self, theMode):
|
||||
"""Set the spell check check box to theMode. This is controlled
|
||||
by the document editor class, which holds the master spell check
|
||||
@@ -166,11 +137,6 @@ class GuiMainMenu(QMenuBar):
|
||||
QDesktopServices.openUrl(QUrl(nw.__docurl__))
|
||||
return True
|
||||
|
||||
def _clearRecentProjects(self):
|
||||
self.mainConf.clearRecent()
|
||||
self.updateRecentProjects()
|
||||
return True
|
||||
|
||||
def _showDocumentLocation(self):
|
||||
self.theParent.docEditor.revealLocation()
|
||||
return True
|
||||
@@ -194,7 +160,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aOpenProject = QAction("Open Project", self)
|
||||
self.aOpenProject.setStatusTip("Open project")
|
||||
self.aOpenProject.setShortcut("Ctrl+Shift+O")
|
||||
self.aOpenProject.triggered.connect(lambda : self.theParent.openProject(None))
|
||||
self.aOpenProject.triggered.connect(self.theParent.manageProjects)
|
||||
self.projMenu.addAction(self.aOpenProject)
|
||||
|
||||
# Project > Save Project
|
||||
@@ -211,10 +177,6 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aCloseProject.triggered.connect(lambda : self.theParent.closeProject(False))
|
||||
self.projMenu.addAction(self.aCloseProject)
|
||||
|
||||
# Project > Recent Projects
|
||||
self.recentMenu = self.projMenu.addMenu("Recent Projects")
|
||||
self.updateRecentProjects()
|
||||
|
||||
# Project > Project Settings
|
||||
self.aProjectSettings = QAction("Project Settings", self)
|
||||
self.aProjectSettings.setStatusTip("Project settings")
|
||||
@@ -255,7 +217,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.rootItems[itemClass].triggered.connect(
|
||||
lambda nCount, itemClass=itemClass : self._newTreeItem(nwItemType.ROOT, itemClass)
|
||||
)
|
||||
self.rootMenu.addActions(self.rootItems.values())
|
||||
self.rootMenu.addAction(self.rootItems[itemClass])
|
||||
|
||||
# Project > New Folder
|
||||
self.aCreateFolder = QAction("Create Folder", self)
|
||||
@@ -281,6 +243,12 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aDeleteItem.triggered.connect(lambda : self.theParent.treeView.deleteItem(None))
|
||||
self.projMenu.addAction(self.aDeleteItem)
|
||||
|
||||
# Project > Empty Trash
|
||||
self.aEmptyTrash = QAction("Empty Trash", self)
|
||||
self.aEmptyTrash.setStatusTip("Permanently delete all files in the Trash folder")
|
||||
self.aEmptyTrash.triggered.connect(self.theParent.treeView.emptyTrash)
|
||||
self.projMenu.addAction(self.aEmptyTrash)
|
||||
|
||||
# Project > Separator
|
||||
self.projMenu.addSeparator()
|
||||
|
||||
@@ -369,6 +337,18 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aImportFile.triggered.connect(self.theParent.importDocument)
|
||||
self.docuMenu.addAction(self.aImportFile)
|
||||
|
||||
# Document > Merge Documents
|
||||
self.aMergeDocs = QAction("Merge Folder to Document", self)
|
||||
self.aMergeDocs.setStatusTip("Merge a folder of documents to a single document")
|
||||
self.aMergeDocs.triggered.connect(self.theParent.mergeDocuments)
|
||||
self.docuMenu.addAction(self.aMergeDocs)
|
||||
|
||||
# Document > Split Document
|
||||
self.aSplitDoc = QAction("Split Document to Folder", self)
|
||||
self.aSplitDoc.setStatusTip("Split a document into a folder of multiple documents")
|
||||
self.aSplitDoc.triggered.connect(self.theParent.splitDocument)
|
||||
self.docuMenu.addAction(self.aSplitDoc)
|
||||
|
||||
return
|
||||
|
||||
def _buildViewMenu(self):
|
||||
@@ -653,7 +633,7 @@ class GuiMainMenu(QMenuBar):
|
||||
self.aReRunSpell = QAction("Re-Run Spell Check", self)
|
||||
self.aReRunSpell.setStatusTip("Run the spell checker on current document")
|
||||
self.aReRunSpell.setShortcut("F7")
|
||||
self.aReRunSpell.triggered.connect(self.theParent.docEditor.reHighlightDocument)
|
||||
self.aReRunSpell.triggered.connect(self.theParent.docEditor.spellCheckDocument)
|
||||
self.toolsMenu.addAction(self.aReRunSpell)
|
||||
|
||||
# Tools > Separator
|
||||
|
||||
Reference in New Issue
Block a user