Skip to content

Commit

Permalink
Add Import/export of status labels (#2152)
Browse files Browse the repository at this point in the history
  • Loading branch information
vkbo authored Dec 30, 2024
2 parents 33aea82 + 4f13746 commit 5911c43
Show file tree
Hide file tree
Showing 7 changed files with 189 additions and 33 deletions.
8 changes: 7 additions & 1 deletion novelwriter/core/projectdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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."""
Expand Down
14 changes: 14 additions & 0 deletions novelwriter/core/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
86 changes: 70 additions & 16 deletions novelwriter/dialogs/projectsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
##
Expand Down
19 changes: 10 additions & 9 deletions novelwriter/dialogs/wordlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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())
Expand All @@ -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:
Expand Down
11 changes: 5 additions & 6 deletions novelwriter/gui/outline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]
Expand Down
14 changes: 14 additions & 0 deletions tests/test_core/test_core_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
70 changes: 69 additions & 1 deletion tests/test_dialogs/test_dlg_projectsettings.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,15 @@
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
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
Expand Down Expand Up @@ -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."""
Expand Down

0 comments on commit 5911c43

Please sign in to comment.