Add Import/export of status labels (#2152)
This commit is contained in:
@@ -29,7 +29,8 @@ import uuid
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from novelwriter.common import (
|
||||
checkBool, checkInt, checkStringNone, checkUuid, isHandle, simplified
|
||||
checkBool, checkInt, checkStringNone, checkUuid, isHandle,
|
||||
makeFileNameSafe, simplified
|
||||
)
|
||||
from novelwriter.core.status import NWStatus
|
||||
|
||||
@@ -101,6 +102,11 @@ class NWProjectData:
|
||||
"""Return the project name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def fileSafeName(self) -> str:
|
||||
"""Return the project name in a file name safe format."""
|
||||
return makeFileNameSafe(self._name)
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
"""Return the project author."""
|
||||
|
||||
@@ -174,6 +174,20 @@ class NWStatus:
|
||||
"""Yield entries from the status icons."""
|
||||
yield from self._store.items()
|
||||
|
||||
def fromRaw(self, data: list[str]) -> StatusEntry | None:
|
||||
"""Create a StatusEntry from a list of three strings consisting
|
||||
of shape, colour, and name. This entry is not automatically
|
||||
added to the list of entries.
|
||||
"""
|
||||
try:
|
||||
shape = nwStatusShape[str(data[0])]
|
||||
color = QColor(str(data[1]))
|
||||
icon = NWStatus.createIcon(self._height, color, shape)
|
||||
return StatusEntry(simplified(data[2]), color, shape, icon)
|
||||
except Exception:
|
||||
logger.error("Could not parse entry %s", str(data))
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def createIcon(height: int, color: QColor, shape: nwStatusShape) -> QIcon:
|
||||
"""Generate an icon for a status label."""
|
||||
|
||||
@@ -24,18 +24,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt5.QtGui import QCloseEvent, QColor
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView, QApplication, QColorDialog, QDialogButtonBox,
|
||||
QHBoxLayout, QLineEdit, QMenu, QStackedWidget, QTreeWidget,
|
||||
QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
QFileDialog, QGridLayout, QHBoxLayout, QLineEdit, QMenu, QStackedWidget,
|
||||
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import qtLambda, simplified
|
||||
from novelwriter.common import formatFileFilter, qtLambda, simplified
|
||||
from novelwriter.constants import nwLabels, trConst
|
||||
from novelwriter.core.status import NWStatus, StatusEntry
|
||||
from novelwriter.enum import nwStatusShape
|
||||
@@ -310,11 +313,13 @@ class _StatusPage(NFixedPage):
|
||||
super().__init__(parent=parent)
|
||||
|
||||
if isStatus:
|
||||
status = SHARED.project.data.itemStatus
|
||||
self._kind = self.tr("Status")
|
||||
self._store = SHARED.project.data.itemStatus
|
||||
pageLabel = self.tr("Novel Document Status Levels")
|
||||
colSetting = "statusColW"
|
||||
else:
|
||||
status = SHARED.project.data.itemImport
|
||||
self._kind = self.tr("Importance")
|
||||
self._store = SHARED.project.data.itemImport
|
||||
pageLabel = self.tr("Project Note Importance Levels")
|
||||
colSetting = "importColW"
|
||||
|
||||
@@ -354,21 +359,33 @@ class _StatusPage(NFixedPage):
|
||||
self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
||||
self.listBox.itemSelectionChanged.connect(self._onSelectionChanged)
|
||||
|
||||
for key, entry in status.iterItems():
|
||||
for key, entry in self._store.iterItems():
|
||||
self._addItem(key, StatusEntry.duplicate(entry))
|
||||
|
||||
# List Controls
|
||||
self.addButton = NIconToolButton(self, iSz, "add")
|
||||
self.addButton.setToolTip(self.tr("Add Label"))
|
||||
self.addButton.clicked.connect(self._onItemCreate)
|
||||
|
||||
self.delButton = NIconToolButton(self, iSz, "remove")
|
||||
self.delButton.setToolTip(self.tr("Delete Label"))
|
||||
self.delButton.clicked.connect(self._onItemDelete)
|
||||
|
||||
self.upButton = NIconToolButton(self, iSz, "up")
|
||||
self.upButton.setToolTip(self.tr("Move Up"))
|
||||
self.upButton.clicked.connect(qtLambda(self._moveItem, -1))
|
||||
|
||||
self.dnButton = NIconToolButton(self, iSz, "down")
|
||||
self.dnButton.clicked.connect(qtLambda(self._moveItem, 1))
|
||||
self.downButton = NIconToolButton(self, iSz, "down")
|
||||
self.downButton.setToolTip(self.tr("Move Down"))
|
||||
self.downButton.clicked.connect(qtLambda(self._moveItem, 1))
|
||||
|
||||
self.importButton = NIconToolButton(self, iSz, "import")
|
||||
self.importButton.setToolTip(self.tr("Import Labels"))
|
||||
self.importButton.clicked.connect(self._importLabels)
|
||||
|
||||
self.exportButton = NIconToolButton(self, iSz, "export")
|
||||
self.exportButton.setToolTip(self.tr("Export Labels"))
|
||||
self.exportButton.clicked.connect(self._exportLabels)
|
||||
|
||||
# Edit Form
|
||||
self.labelText = QLineEdit(self)
|
||||
@@ -414,21 +431,22 @@ class _StatusPage(NFixedPage):
|
||||
self.listControls.addWidget(self.addButton)
|
||||
self.listControls.addWidget(self.delButton)
|
||||
self.listControls.addWidget(self.upButton)
|
||||
self.listControls.addWidget(self.dnButton)
|
||||
self.listControls.addWidget(self.downButton)
|
||||
self.listControls.addStretch(1)
|
||||
self.listControls.addWidget(self.importButton)
|
||||
self.listControls.addWidget(self.exportButton)
|
||||
|
||||
self.editBox = QHBoxLayout()
|
||||
self.editBox.addWidget(self.labelText, 1)
|
||||
self.editBox.addWidget(self.colorButton, 0)
|
||||
self.editBox.addWidget(self.shapeButton, 0)
|
||||
|
||||
self.mainBox = QVBoxLayout()
|
||||
self.mainBox.addWidget(self.listBox, 1)
|
||||
self.mainBox.addLayout(self.editBox, 0)
|
||||
|
||||
self.innerBox = QHBoxLayout()
|
||||
self.innerBox.addLayout(self.mainBox, 1)
|
||||
self.innerBox.addLayout(self.listControls, 0)
|
||||
self.innerBox = QGridLayout()
|
||||
self.innerBox.addWidget(self.listBox, 0, 0)
|
||||
self.innerBox.addLayout(self.listControls, 0, 1)
|
||||
self.innerBox.addLayout(self.editBox, 1, 0)
|
||||
self.innerBox.setRowStretch(0, 1)
|
||||
self.innerBox.setColumnStretch(0, 1)
|
||||
|
||||
self.outerBox = QVBoxLayout()
|
||||
self.outerBox.addWidget(self.pageTitle, 0)
|
||||
@@ -540,6 +558,42 @@ class _StatusPage(NFixedPage):
|
||||
self.shapeButton.setEnabled(False)
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _importLabels(self) -> None:
|
||||
"""Import labels from file."""
|
||||
if path := QFileDialog.getOpenFileName(
|
||||
self, self.tr("Import File"),
|
||||
str(CONFIG.homePath()), filter=formatFileFilter(["*.csv", "*"]),
|
||||
)[0]:
|
||||
try:
|
||||
with open(path, mode="r", encoding="utf-8") as fo:
|
||||
for row in csv.reader(fo):
|
||||
if entry := self._store.fromRaw(row):
|
||||
self._addItem(None, entry)
|
||||
except Exception as exc:
|
||||
SHARED.error("Could not read file.", exc=exc)
|
||||
return
|
||||
return
|
||||
|
||||
@pyqtSlot()
|
||||
def _exportLabels(self) -> None:
|
||||
"""Export labels to file."""
|
||||
name = f"{SHARED.project.data.fileSafeName} - {self._kind}.csv"
|
||||
if path := QFileDialog.getSaveFileName(
|
||||
self, self.tr("Export File"), str(CONFIG.homePath() / name),
|
||||
)[0]:
|
||||
try:
|
||||
path = Path(path).with_suffix(".csv")
|
||||
with open(path, mode="w", encoding="utf-8") as fo:
|
||||
writer = csv.writer(fo)
|
||||
for n in range(self.listBox.topLevelItemCount()):
|
||||
if item := self.listBox.topLevelItem(n):
|
||||
entry: StatusEntry = item.data(self.C_DATA, self.D_ENTRY)
|
||||
writer.writerow([entry.shape.name, entry.color.name(), entry.name])
|
||||
except Exception as exc:
|
||||
SHARED.error("Could not write file.", exc=exc)
|
||||
return
|
||||
|
||||
##
|
||||
# Internal Functions
|
||||
##
|
||||
|
||||
@@ -96,9 +96,11 @@ class GuiWordList(NDialog):
|
||||
self.newEntry = QLineEdit(self)
|
||||
|
||||
self.addButton = NIconToolButton(self, iSz, "add")
|
||||
self.addButton.setToolTip(self.tr("Add Word"))
|
||||
self.addButton.clicked.connect(self._doAdd)
|
||||
|
||||
self.delButton = NIconToolButton(self, iSz, "remove")
|
||||
self.delButton.setToolTip(self.tr("Remove Word"))
|
||||
self.delButton.clicked.connect(self._doDelete)
|
||||
|
||||
self.editBox = QHBoxLayout()
|
||||
@@ -184,11 +186,10 @@ class GuiWordList(NDialog):
|
||||
SHARED.info(self.tr(
|
||||
"Note: The import file must be a plain text file with UTF-8 or ASCII encoding."
|
||||
))
|
||||
ffilter = formatFileFilter(["*.txt", "*"])
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self, self.tr("Import File"), str(CONFIG.homePath()), filter=ffilter
|
||||
)
|
||||
if path:
|
||||
if path := QFileDialog.getOpenFileName(
|
||||
self, self.tr("Import File"), str(CONFIG.homePath()),
|
||||
filter=formatFileFilter(["*.txt", "*"]),
|
||||
)[0]:
|
||||
try:
|
||||
with open(path, mode="r", encoding="utf-8") as fo:
|
||||
words = set(w.strip() for w in fo.read().split())
|
||||
@@ -202,10 +203,10 @@ class GuiWordList(NDialog):
|
||||
@pyqtSlot()
|
||||
def _exportWords(self) -> None:
|
||||
"""Export words to file."""
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, self.tr("Export File"), str(CONFIG.homePath())
|
||||
)
|
||||
if path:
|
||||
name = f"{SHARED.project.data.fileSafeName} - {self.windowTitle()}.txt"
|
||||
if path := QFileDialog.getSaveFileName(
|
||||
self, self.tr("Export File"), str(CONFIG.homePath() / name),
|
||||
)[0]:
|
||||
try:
|
||||
path = Path(path).with_suffix(".txt")
|
||||
with open(path, mode="w", encoding="utf-8") as fo:
|
||||
|
||||
@@ -41,7 +41,7 @@ from PyQt5.QtWidgets import (
|
||||
)
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.common import checkInt, formatFileFilter, makeFileNameSafe
|
||||
from novelwriter.common import checkInt, formatFileFilter
|
||||
from novelwriter.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst
|
||||
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
|
||||
from novelwriter.error import logException
|
||||
@@ -541,11 +541,10 @@ class GuiOutlineTree(QTreeWidget):
|
||||
@pyqtSlot()
|
||||
def exportOutline(self) -> None:
|
||||
"""Export the outline as a CSV file."""
|
||||
path = CONFIG.lastPath("outline") / f"{makeFileNameSafe(SHARED.project.data.name)}.csv"
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self, self.tr("Save Outline As"), str(path), formatFileFilter(["*.csv", "*"])
|
||||
)
|
||||
if path:
|
||||
name = CONFIG.lastPath("outline") / f"{SHARED.project.data.fileSafeName}.csv"
|
||||
if path := QFileDialog.getSaveFileName(
|
||||
self, self.tr("Save Outline As"), str(name), formatFileFilter(["*.csv", "*"])
|
||||
)[0]:
|
||||
CONFIG.setLastPath("outline", path)
|
||||
logger.info("Writing CSV file: %s", path)
|
||||
cols = [col for col in self._treeOrder if not self._colHidden[col]]
|
||||
|
||||
@@ -303,6 +303,20 @@ def testCoreStatus_Entries(mockGUI, mockRnd):
|
||||
assert list(nStatus._store.keys()) == [statusKeys[3], statusKeys[2]]
|
||||
assert nStatus._default == statusKeys[3]
|
||||
|
||||
# Create
|
||||
# ======
|
||||
|
||||
# A valid entry
|
||||
entry = nStatus.fromRaw(["STAR", "#ff7f00", "Test"])
|
||||
assert entry is not None
|
||||
assert entry.shape == nwStatusShape.STAR
|
||||
assert entry.color == QColor(255, 127, 0)
|
||||
assert entry.name == "Test"
|
||||
|
||||
# Invalid entries
|
||||
assert nStatus.fromRaw(["STAR", "#ff7f00"]) is None
|
||||
assert nStatus.fromRaw(["STUFF", "#ff7f00", "Test"]) is None
|
||||
|
||||
|
||||
@pytest.mark.core
|
||||
def testCoreStatus_Pack(mockGUI, mockRnd):
|
||||
|
||||
@@ -23,7 +23,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtWidgets import QAction, QColorDialog
|
||||
from PyQt5.QtWidgets import QAction, QColorDialog, QFileDialog
|
||||
|
||||
from novelwriter import CONFIG, SHARED
|
||||
from novelwriter.dialogs.editlabel import GuiEditLabel
|
||||
@@ -31,6 +31,7 @@ from novelwriter.dialogs.projectsettings import GuiProjectSettings
|
||||
from novelwriter.enum import nwItemType, nwStatusShape
|
||||
from novelwriter.types import QtAccepted, QtMouseLeft
|
||||
|
||||
from tests.mocked import causeOSError
|
||||
from tests.tools import C, buildTestProject
|
||||
|
||||
KEY_DELAY = 1
|
||||
@@ -319,6 +320,73 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, projPath, mockRn
|
||||
# qtbot.stop()
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testDlgProjSettings_StatusImportExport(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the status and importance import/export."""
|
||||
buildTestProject(nwGUI, projPath)
|
||||
|
||||
# Create Dialog
|
||||
projSettings = GuiProjectSettings(nwGUI, GuiProjectSettings.PAGE_STATUS)
|
||||
projSettings.show()
|
||||
qtbot.addWidget(projSettings)
|
||||
|
||||
status = projSettings.statusPage
|
||||
assert status.listBox.topLevelItemCount() == 4
|
||||
expFile = projPath / "status.csv"
|
||||
|
||||
# Export Error
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (str(expFile), ""))
|
||||
mp.setattr("builtins.open", causeOSError)
|
||||
status._exportLabels()
|
||||
|
||||
assert expFile.is_file() is False
|
||||
|
||||
# Export File
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (str(expFile), ""))
|
||||
status._exportLabels()
|
||||
|
||||
assert expFile.is_file() is True
|
||||
assert expFile.read_text().split() == [
|
||||
"SQUARE,#646464,New",
|
||||
"SQUARE,#c83200,Note",
|
||||
"SQUARE,#c89600,Draft",
|
||||
"SQUARE,#32c800,Finished",
|
||||
]
|
||||
|
||||
# Import Error
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(expFile), ""))
|
||||
mp.setattr("builtins.open", causeOSError)
|
||||
status._importLabels()
|
||||
|
||||
assert status.listBox.topLevelItemCount() == 4
|
||||
|
||||
# Import File
|
||||
with monkeypatch.context() as mp:
|
||||
mp.setattr(QFileDialog, "getOpenFileName", lambda *a, **k: (str(expFile), ""))
|
||||
status._importLabels()
|
||||
|
||||
assert status.listBox.topLevelItemCount() == 8
|
||||
|
||||
item4 = status.listBox.topLevelItem(4)
|
||||
assert item4 is not None
|
||||
assert item4.text(0) == "New"
|
||||
|
||||
item5 = status.listBox.topLevelItem(5)
|
||||
assert item5 is not None
|
||||
assert item5.text(0) == "Note"
|
||||
|
||||
item6 = status.listBox.topLevelItem(6)
|
||||
assert item6 is not None
|
||||
assert item6.text(0) == "Draft"
|
||||
|
||||
item7 = status.listBox.topLevelItem(7)
|
||||
assert item7 is not None
|
||||
assert item7.text(0) == "Finished"
|
||||
|
||||
|
||||
@pytest.mark.gui
|
||||
def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
|
||||
"""Test the auto-replace page of the dialog."""
|
||||
|
||||
Reference in New Issue
Block a user