Add Import/export of status labels (#2152)

This commit is contained in:
Veronica Berglyd Olsen
2024-12-31 00:40:52 +01:00
committed by GitHub
7 changed files with 189 additions and 33 deletions
+7 -1
View File
@@ -29,7 +29,8 @@ import uuid
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from novelwriter.common import ( from novelwriter.common import (
checkBool, checkInt, checkStringNone, checkUuid, isHandle, simplified checkBool, checkInt, checkStringNone, checkUuid, isHandle,
makeFileNameSafe, simplified
) )
from novelwriter.core.status import NWStatus from novelwriter.core.status import NWStatus
@@ -101,6 +102,11 @@ class NWProjectData:
"""Return the project name.""" """Return the project name."""
return self._name return self._name
@property
def fileSafeName(self) -> str:
"""Return the project name in a file name safe format."""
return makeFileNameSafe(self._name)
@property @property
def author(self) -> str: def author(self) -> str:
"""Return the project author.""" """Return the project author."""
+14
View File
@@ -174,6 +174,20 @@ class NWStatus:
"""Yield entries from the status icons.""" """Yield entries from the status icons."""
yield from self._store.items() 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 @staticmethod
def createIcon(height: int, color: QColor, shape: nwStatusShape) -> QIcon: def createIcon(height: int, color: QColor, shape: nwStatusShape) -> QIcon:
"""Generate an icon for a status label.""" """Generate an icon for a status label."""
+70 -16
View File
@@ -24,18 +24,21 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
""" """
from __future__ import annotations from __future__ import annotations
import csv
import logging import logging
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot
from PyQt5.QtGui import QCloseEvent, QColor from PyQt5.QtGui import QCloseEvent, QColor
from PyQt5.QtWidgets import ( from PyQt5.QtWidgets import (
QAbstractItemView, QApplication, QColorDialog, QDialogButtonBox, QAbstractItemView, QApplication, QColorDialog, QDialogButtonBox,
QHBoxLayout, QLineEdit, QMenu, QStackedWidget, QTreeWidget, QFileDialog, QGridLayout, QHBoxLayout, QLineEdit, QMenu, QStackedWidget,
QTreeWidgetItem, QVBoxLayout, QWidget QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
) )
from novelwriter import CONFIG, SHARED 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.constants import nwLabels, trConst
from novelwriter.core.status import NWStatus, StatusEntry from novelwriter.core.status import NWStatus, StatusEntry
from novelwriter.enum import nwStatusShape from novelwriter.enum import nwStatusShape
@@ -310,11 +313,13 @@ class _StatusPage(NFixedPage):
super().__init__(parent=parent) super().__init__(parent=parent)
if isStatus: 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") pageLabel = self.tr("Novel Document Status Levels")
colSetting = "statusColW" colSetting = "statusColW"
else: else:
status = SHARED.project.data.itemImport self._kind = self.tr("Importance")
self._store = SHARED.project.data.itemImport
pageLabel = self.tr("Project Note Importance Levels") pageLabel = self.tr("Project Note Importance Levels")
colSetting = "importColW" colSetting = "importColW"
@@ -354,21 +359,33 @@ class _StatusPage(NFixedPage):
self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.listBox.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.listBox.itemSelectionChanged.connect(self._onSelectionChanged) 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)) self._addItem(key, StatusEntry.duplicate(entry))
# List Controls # List Controls
self.addButton = NIconToolButton(self, iSz, "add") self.addButton = NIconToolButton(self, iSz, "add")
self.addButton.setToolTip(self.tr("Add Label"))
self.addButton.clicked.connect(self._onItemCreate) self.addButton.clicked.connect(self._onItemCreate)
self.delButton = NIconToolButton(self, iSz, "remove") self.delButton = NIconToolButton(self, iSz, "remove")
self.delButton.setToolTip(self.tr("Delete Label"))
self.delButton.clicked.connect(self._onItemDelete) self.delButton.clicked.connect(self._onItemDelete)
self.upButton = NIconToolButton(self, iSz, "up") self.upButton = NIconToolButton(self, iSz, "up")
self.upButton.setToolTip(self.tr("Move Up"))
self.upButton.clicked.connect(qtLambda(self._moveItem, -1)) self.upButton.clicked.connect(qtLambda(self._moveItem, -1))
self.dnButton = NIconToolButton(self, iSz, "down") self.downButton = NIconToolButton(self, iSz, "down")
self.dnButton.clicked.connect(qtLambda(self._moveItem, 1)) 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 # Edit Form
self.labelText = QLineEdit(self) self.labelText = QLineEdit(self)
@@ -414,21 +431,22 @@ class _StatusPage(NFixedPage):
self.listControls.addWidget(self.addButton) self.listControls.addWidget(self.addButton)
self.listControls.addWidget(self.delButton) self.listControls.addWidget(self.delButton)
self.listControls.addWidget(self.upButton) self.listControls.addWidget(self.upButton)
self.listControls.addWidget(self.dnButton) self.listControls.addWidget(self.downButton)
self.listControls.addStretch(1) self.listControls.addStretch(1)
self.listControls.addWidget(self.importButton)
self.listControls.addWidget(self.exportButton)
self.editBox = QHBoxLayout() self.editBox = QHBoxLayout()
self.editBox.addWidget(self.labelText, 1) self.editBox.addWidget(self.labelText, 1)
self.editBox.addWidget(self.colorButton, 0) self.editBox.addWidget(self.colorButton, 0)
self.editBox.addWidget(self.shapeButton, 0) self.editBox.addWidget(self.shapeButton, 0)
self.mainBox = QVBoxLayout() self.innerBox = QGridLayout()
self.mainBox.addWidget(self.listBox, 1) self.innerBox.addWidget(self.listBox, 0, 0)
self.mainBox.addLayout(self.editBox, 0) self.innerBox.addLayout(self.listControls, 0, 1)
self.innerBox.addLayout(self.editBox, 1, 0)
self.innerBox = QHBoxLayout() self.innerBox.setRowStretch(0, 1)
self.innerBox.addLayout(self.mainBox, 1) self.innerBox.setColumnStretch(0, 1)
self.innerBox.addLayout(self.listControls, 0)
self.outerBox = QVBoxLayout() self.outerBox = QVBoxLayout()
self.outerBox.addWidget(self.pageTitle, 0) self.outerBox.addWidget(self.pageTitle, 0)
@@ -540,6 +558,42 @@ class _StatusPage(NFixedPage):
self.shapeButton.setEnabled(False) self.shapeButton.setEnabled(False)
return 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 # Internal Functions
## ##
+10 -9
View File
@@ -96,9 +96,11 @@ class GuiWordList(NDialog):
self.newEntry = QLineEdit(self) self.newEntry = QLineEdit(self)
self.addButton = NIconToolButton(self, iSz, "add") self.addButton = NIconToolButton(self, iSz, "add")
self.addButton.setToolTip(self.tr("Add Word"))
self.addButton.clicked.connect(self._doAdd) self.addButton.clicked.connect(self._doAdd)
self.delButton = NIconToolButton(self, iSz, "remove") self.delButton = NIconToolButton(self, iSz, "remove")
self.delButton.setToolTip(self.tr("Remove Word"))
self.delButton.clicked.connect(self._doDelete) self.delButton.clicked.connect(self._doDelete)
self.editBox = QHBoxLayout() self.editBox = QHBoxLayout()
@@ -184,11 +186,10 @@ class GuiWordList(NDialog):
SHARED.info(self.tr( SHARED.info(self.tr(
"Note: The import file must be a plain text file with UTF-8 or ASCII encoding." "Note: The import file must be a plain text file with UTF-8 or ASCII encoding."
)) ))
ffilter = formatFileFilter(["*.txt", "*"]) if path := QFileDialog.getOpenFileName(
path, _ = QFileDialog.getOpenFileName( self, self.tr("Import File"), str(CONFIG.homePath()),
self, self.tr("Import File"), str(CONFIG.homePath()), filter=ffilter filter=formatFileFilter(["*.txt", "*"]),
) )[0]:
if path:
try: try:
with open(path, mode="r", encoding="utf-8") as fo: with open(path, mode="r", encoding="utf-8") as fo:
words = set(w.strip() for w in fo.read().split()) words = set(w.strip() for w in fo.read().split())
@@ -202,10 +203,10 @@ class GuiWordList(NDialog):
@pyqtSlot() @pyqtSlot()
def _exportWords(self) -> None: def _exportWords(self) -> None:
"""Export words to file.""" """Export words to file."""
path, _ = QFileDialog.getSaveFileName( name = f"{SHARED.project.data.fileSafeName} - {self.windowTitle()}.txt"
self, self.tr("Export File"), str(CONFIG.homePath()) if path := QFileDialog.getSaveFileName(
) self, self.tr("Export File"), str(CONFIG.homePath() / name),
if path: )[0]:
try: try:
path = Path(path).with_suffix(".txt") path = Path(path).with_suffix(".txt")
with open(path, mode="w", encoding="utf-8") as fo: with open(path, mode="w", encoding="utf-8") as fo:
+5 -6
View File
@@ -41,7 +41,7 @@ from PyQt5.QtWidgets import (
) )
from novelwriter import CONFIG, SHARED 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.constants import nwKeyWords, nwLabels, nwStats, nwStyles, trConst
from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline from novelwriter.enum import nwChange, nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
from novelwriter.error import logException from novelwriter.error import logException
@@ -541,11 +541,10 @@ class GuiOutlineTree(QTreeWidget):
@pyqtSlot() @pyqtSlot()
def exportOutline(self) -> None: def exportOutline(self) -> None:
"""Export the outline as a CSV file.""" """Export the outline as a CSV file."""
path = CONFIG.lastPath("outline") / f"{makeFileNameSafe(SHARED.project.data.name)}.csv" name = CONFIG.lastPath("outline") / f"{SHARED.project.data.fileSafeName}.csv"
path, _ = QFileDialog.getSaveFileName( if path := QFileDialog.getSaveFileName(
self, self.tr("Save Outline As"), str(path), formatFileFilter(["*.csv", "*"]) self, self.tr("Save Outline As"), str(name), formatFileFilter(["*.csv", "*"])
) )[0]:
if path:
CONFIG.setLastPath("outline", path) CONFIG.setLastPath("outline", path)
logger.info("Writing CSV file: %s", path) logger.info("Writing CSV file: %s", path)
cols = [col for col in self._treeOrder if not self._colHidden[col]] cols = [col for col in self._treeOrder if not self._colHidden[col]]
+14
View File
@@ -303,6 +303,20 @@ def testCoreStatus_Entries(mockGUI, mockRnd):
assert list(nStatus._store.keys()) == [statusKeys[3], statusKeys[2]] assert list(nStatus._store.keys()) == [statusKeys[3], statusKeys[2]]
assert nStatus._default == statusKeys[3] 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 @pytest.mark.core
def testCoreStatus_Pack(mockGUI, mockRnd): def testCoreStatus_Pack(mockGUI, mockRnd):
+69 -1
View File
@@ -23,7 +23,7 @@ from __future__ import annotations
import pytest import pytest
from PyQt5.QtGui import QColor 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 import CONFIG, SHARED
from novelwriter.dialogs.editlabel import GuiEditLabel from novelwriter.dialogs.editlabel import GuiEditLabel
@@ -31,6 +31,7 @@ from novelwriter.dialogs.projectsettings import GuiProjectSettings
from novelwriter.enum import nwItemType, nwStatusShape from novelwriter.enum import nwItemType, nwStatusShape
from novelwriter.types import QtAccepted, QtMouseLeft from novelwriter.types import QtAccepted, QtMouseLeft
from tests.mocked import causeOSError
from tests.tools import C, buildTestProject from tests.tools import C, buildTestProject
KEY_DELAY = 1 KEY_DELAY = 1
@@ -319,6 +320,73 @@ def testDlgProjSettings_StatusImport(qtbot, monkeypatch, nwGUI, projPath, mockRn
# qtbot.stop() # 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 @pytest.mark.gui
def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, projPath, mockRnd): def testDlgProjSettings_Replace(qtbot, monkeypatch, nwGUI, projPath, mockRnd):
"""Test the auto-replace page of the dialog.""" """Test the auto-replace page of the dialog."""