diff --git a/novelwriter/core/projectdata.py b/novelwriter/core/projectdata.py index 6d47b9556..a7ea319bb 100644 --- a/novelwriter/core/projectdata.py +++ b/novelwriter/core/projectdata.py @@ -29,7 +29,8 @@ 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 @@ def name(self) -> str: """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.""" diff --git a/novelwriter/core/status.py b/novelwriter/core/status.py index fa3a3dfc1..acf21a9a7 100644 --- a/novelwriter/core/status.py +++ b/novelwriter/core/status.py @@ -174,6 +174,20 @@ def iterItems(self) -> Iterable[tuple[str, StatusEntry]]: """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.""" diff --git a/novelwriter/dialogs/projectsettings.py b/novelwriter/dialogs/projectsettings.py index e31a8f63b..a1ad9acfc 100644 --- a/novelwriter/dialogs/projectsettings.py +++ b/novelwriter/dialogs/projectsettings.py @@ -24,18 +24,21 @@ """ 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 @@ def __init__(self, parent: QWidget, isStatus: bool) -> None: 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 @@ def __init__(self, parent: QWidget, isStatus: bool) -> None: 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 @@ def buildMenu(menu: QMenu, items: dict[nwStatusShape, str]) -> None: 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 @@ def _onSelectionChanged(self) -> None: 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 ## diff --git a/novelwriter/dialogs/wordlist.py b/novelwriter/dialogs/wordlist.py index 8009d1573..a89f0308e 100644 --- a/novelwriter/dialogs/wordlist.py +++ b/novelwriter/dialogs/wordlist.py @@ -96,9 +96,11 @@ def __init__(self, parent: QWidget) -> None: 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 @@ def _importWords(self) -> None: 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 @@ def _importWords(self) -> None: @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: diff --git a/novelwriter/gui/outline.py b/novelwriter/gui/outline.py index e07f3c841..8678ebb65 100644 --- a/novelwriter/gui/outline.py +++ b/novelwriter/gui/outline.py @@ -41,7 +41,7 @@ ) 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 @@ def menuColumnToggled(self, isChecked: bool, hItem: nwOutline) -> None: @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]] diff --git a/tests/test_core/test_core_status.py b/tests/test_core/test_core_status.py index e38690436..8f0974571 100644 --- a/tests/test_core/test_core_status.py +++ b/tests/test_core/test_core_status.py @@ -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): diff --git a/tests/test_dialogs/test_dlg_projectsettings.py b/tests/test_dialogs/test_dlg_projectsettings.py index 1d009a70a..93714a81c 100644 --- a/tests/test_dialogs/test_dlg_projectsettings.py +++ b/tests/test_dialogs/test_dlg_projectsettings.py @@ -23,7 +23,7 @@ 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.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."""