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 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."""
+14
View File
@@ -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."""
+70 -16
View File
@@ -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
##
+10 -9
View File
@@ -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:
+5 -6
View File
@@ -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]]
+14
View File
@@ -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):
+69 -1
View File
@@ -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."""