Skip to content

Commit b741b21

Browse files
authored
Add CSV export for Outline View (#1697)
2 parents 1a635f6 + a350d38 commit b741b21

File tree

6 files changed

+142
-79
lines changed

6 files changed

+142
-79
lines changed

novelwriter/constants.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ class nwLabels:
274274
"*.txt": QT_TRANSLATE_NOOP("Constant", "Text files"),
275275
"*.md": QT_TRANSLATE_NOOP("Constant", "Markdown files"),
276276
"*.nwd": QT_TRANSLATE_NOOP("Constant", "novelWriter files"),
277+
"*.csv": QT_TRANSLATE_NOOP("Constant", "CSV files"),
277278
"*": QT_TRANSLATE_NOOP("Constant", "All files"),
278279
}
279280
UNIT_NAME = {

novelwriter/gui/outline.py

Lines changed: 90 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -27,26 +27,25 @@
2727
"""
2828
from __future__ import annotations
2929

30+
import csv
3031
import logging
3132

3233
from time import time
3334
from enum import Enum
3435

35-
from PyQt5.QtCore import (
36-
Qt, pyqtSignal, pyqtSlot, QSize, QT_TRANSLATE_NOOP
37-
)
36+
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QSize, QT_TRANSLATE_NOOP
3837
from PyQt5.QtWidgets import (
39-
QAbstractItemView, QAction, QFrame, QGridLayout, QGroupBox, QHBoxLayout,
40-
QLabel, QMenu, QScrollArea, QSizePolicy, QSplitter, QToolBar, QToolButton,
41-
QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
38+
QAbstractItemView, QAction, QFileDialog, QFrame, QGridLayout, QGroupBox,
39+
QHBoxLayout, QLabel, QMenu, QScrollArea, QSizePolicy, QSplitter, QToolBar,
40+
QToolButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
4241
)
4342

4443
from novelwriter import CONFIG, SHARED
4544
from novelwriter.enum import (
4645
nwDocMode, nwItemClass, nwItemLayout, nwItemType, nwOutline
4746
)
4847
from novelwriter.error import logException
49-
from novelwriter.common import checkInt
48+
from novelwriter.common import checkInt, formatFileFilter, makeFileNameSafe
5049
from novelwriter.constants import nwHeaders, trConst, nwKeyWords, nwLabels
5150
from novelwriter.extensions.novelselector import NovelSelector
5251

@@ -88,6 +87,7 @@ def __init__(self, parent: QWidget) -> None:
8887
self.outlineData.itemTagClicked.connect(self._tagClicked)
8988
self.outlineBar.loadNovelRootRequest.connect(self._rootItemChanged)
9089
self.outlineBar.viewColumnToggled.connect(self.outlineTree.menuColumnToggled)
90+
self.outlineBar.outlineExportRequest.connect(self.outlineTree.exportOutline)
9191

9292
# Function Mappings
9393
self.getSelectedHandle = self.outlineTree.getSelectedHandle
@@ -124,7 +124,7 @@ def clearOutline(self) -> None:
124124
def openProjectTasks(self) -> None:
125125
"""Run open project tasks."""
126126
lastOutline = SHARED.project.data.getLastHandle("outline")
127-
if not (lastOutline is None or lastOutline in SHARED.project.tree):
127+
if not lastOutline or lastOutline not in SHARED.project.tree:
128128
lastOutline = SHARED.project.tree.findRoot(nwItemClass.NOVEL)
129129

130130
logger.debug("Setting outline tree to root item '%s'", lastOutline)
@@ -198,6 +198,7 @@ def _rootItemChanged(self, tHandle) -> None:
198198
class GuiOutlineToolBar(QToolBar):
199199

200200
loadNovelRootRequest = pyqtSignal(str)
201+
outlineExportRequest = pyqtSignal()
201202
viewColumnToggled = pyqtSignal(bool, Enum)
202203

203204
def __init__(self, outlineView: GuiOutlineView) -> None:
@@ -228,6 +229,9 @@ def __init__(self, outlineView: GuiOutlineView) -> None:
228229
self.aRefresh = QAction(self.tr("Refresh"), self)
229230
self.aRefresh.triggered.connect(self._refreshRequested)
230231

232+
self.aExport = QAction(self.tr("Export CSV"), self)
233+
self.aExport.triggered.connect(self._exportRequested)
234+
231235
# Column Menu
232236
self.mColumns = GuiOutlineHeaderMenu(self)
233237
self.mColumns.columnToggled.connect(
@@ -243,6 +247,7 @@ def __init__(self, outlineView: GuiOutlineView) -> None:
243247
self.addWidget(self.novelValue)
244248
self.addSeparator()
245249
self.addAction(self.aRefresh)
250+
self.addAction(self.aExport)
246251
self.addWidget(self.tbColumns)
247252
self.addWidget(stretch)
248253

@@ -261,6 +266,7 @@ def updateTheme(self) -> None:
261266
self.setStyleSheet("QToolBar {border: 0px;}")
262267
self.novelValue.refreshNovelList()
263268
self.aRefresh.setIcon(SHARED.theme.getIcon("refresh"))
269+
self.aExport.setIcon(SHARED.theme.getIcon("export"))
264270
self.tbColumns.setIcon(SHARED.theme.getIcon("menu"))
265271
self.tbColumns.setStyleSheet("QToolButton::menu-indicator {image: none;}")
266272
return
@@ -296,6 +302,12 @@ def _refreshRequested(self) -> None:
296302
self.loadNovelRootRequest.emit(self.novelValue.handle)
297303
return
298304

305+
@pyqtSlot()
306+
def _exportRequested(self) -> None:
307+
"""Emit a signal that an export of the outline was requested."""
308+
self.outlineExportRequest.emit()
309+
return
310+
299311
# END Class GuiOutlineToolBar
300312

301313

@@ -492,15 +504,48 @@ def getSelectedHandle(self) -> tuple[str | None, str | None]:
492504
"""Get the currently selected handle. If multiple items are
493505
selected, return the first.
494506
"""
495-
selItem = self.selectedItems()
496-
if selItem:
497-
tHandle = selItem[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
498-
sTitle = selItem[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
507+
if item := self.selectedItems():
508+
tHandle = item[0].data(self._colIdx[nwOutline.TITLE], self.D_HANDLE)
509+
sTitle = item[0].data(self._colIdx[nwOutline.TITLE], self.D_TITLE)
499510
return tHandle, sTitle
500511
return None, None
501512

502513
##
503-
# Slots
514+
# Public Slots
515+
##
516+
517+
@pyqtSlot(bool, Enum)
518+
def menuColumnToggled(self, isChecked: bool, hItem: nwOutline) -> None:
519+
"""Receive the changes to column visibility forwarded by the
520+
column selection menu.
521+
"""
522+
if hItem in self._colIdx:
523+
self.setColumnHidden(self._colIdx[hItem], not isChecked)
524+
self._saveHeaderState()
525+
return
526+
527+
@pyqtSlot()
528+
def exportOutline(self) -> None:
529+
"""Export the outline as a CSV file."""
530+
path = CONFIG.lastPath() / f"{makeFileNameSafe(SHARED.project.data.name)}.csv"
531+
path, _ = QFileDialog.getSaveFileName(
532+
self, self.tr("Save Outline As"), str(path), formatFileFilter(["*.csv", "*"])
533+
)
534+
if path:
535+
CONFIG.setLastPath(path)
536+
logger.info("Writing CSV file: %s", path)
537+
cols = [col for col in self._treeOrder if not self._colHidden[col]]
538+
order = [self._colIdx[col] for col in cols]
539+
with open(path, mode="w", newline="") as csvFile:
540+
writer = csv.writer(csvFile, dialect="excel", quoting=csv.QUOTE_ALL)
541+
writer.writerow([trConst(nwLabels.OUTLINE_COLS[col]) for col in cols])
542+
for i in range(self.topLevelItemCount()):
543+
if item := self.topLevelItem(i):
544+
writer.writerow(item.text(i) for i in order)
545+
return
546+
547+
##
548+
# Private Slots
504549
##
505550

506551
@pyqtSlot("QTreeWidgetItem*", int)
@@ -510,9 +555,8 @@ def _treeDoubleClick(self, tItem: QTreeWidgetItem, tCol: int) -> None:
510555
document editor.
511556
"""
512557
tHandle, sTitle = self.getSelectedHandle()
513-
if tHandle is None:
514-
return
515-
self.outlineView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True)
558+
if tHandle:
559+
self.outlineView.openDocumentRequest.emit(tHandle, nwDocMode.EDIT, sTitle or "", True)
516560
return
517561

518562
@pyqtSlot()
@@ -536,16 +580,6 @@ def _columnMoved(self, logIdx: int, oldVisualIdx: int, newVisualIdx: int) -> Non
536580
self._saveHeaderState()
537581
return
538582

539-
@pyqtSlot(bool, Enum)
540-
def menuColumnToggled(self, isChecked: bool, hItem: nwOutline) -> None:
541-
"""Receive the changes to column visibility forwarded by the
542-
column selection menu.
543-
"""
544-
if hItem in self._colIdx:
545-
self.setColumnHidden(self._colIdx[hItem], not isChecked)
546-
self._saveHeaderState()
547-
return
548-
549583
##
550584
# Internal Functions
551585
##
@@ -843,18 +877,15 @@ def __init__(self, outlineView: GuiOutlineView) -> None:
843877
self.entKeyValue.setWordWrap(True)
844878
self.cstKeyValue.setWordWrap(True)
845879

846-
def tagClicked(link):
847-
self.itemTagClicked.emit(link)
848-
849-
self.povKeyValue.linkActivated.connect(tagClicked)
850-
self.focKeyValue.linkActivated.connect(tagClicked)
851-
self.chrKeyValue.linkActivated.connect(tagClicked)
852-
self.pltKeyValue.linkActivated.connect(tagClicked)
853-
self.timKeyValue.linkActivated.connect(tagClicked)
854-
self.wldKeyValue.linkActivated.connect(tagClicked)
855-
self.objKeyValue.linkActivated.connect(tagClicked)
856-
self.entKeyValue.linkActivated.connect(tagClicked)
857-
self.cstKeyValue.linkActivated.connect(tagClicked)
880+
self.povKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
881+
self.focKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
882+
self.chrKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
883+
self.pltKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
884+
self.timKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
885+
self.wldKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
886+
self.objKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
887+
self.entKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
888+
self.cstKeyValue.linkActivated.connect(lambda x: self.itemTagClicked.emit(x))
858889

859890
self.povKeyLWrap.addWidget(self.povKeyValue, 1)
860891
self.focKeyLWrap.addWidget(self.focKeyValue, 1)
@@ -983,49 +1014,40 @@ def clearDetails(self) -> None:
9831014
##
9841015

9851016
@pyqtSlot(str, str)
986-
def showItem(self, tHandle: str, sTitle: str) -> bool:
1017+
def showItem(self, tHandle: str, sTitle: str) -> None:
9871018
"""Update the content of the tree with the given handle and line
9881019
number pointing to a header.
9891020
"""
9901021
pIndex = SHARED.project.index
9911022
nwItem = SHARED.project.tree[tHandle]
9921023
novIdx = pIndex.getItemHeader(tHandle, sTitle)
9931024
novRefs = pIndex.getReferences(tHandle, sTitle)
994-
if nwItem is None or novIdx is None:
995-
return False
996-
997-
if novIdx.level in self.LVL_MAP:
998-
self.titleLabel.setText("<b>%s</b>" % self.tr(self.LVL_MAP[novIdx.level]))
999-
else:
1000-
self.titleLabel.setText("<b>%s</b>" % self.tr("Title"))
1001-
self.titleValue.setText(novIdx.title)
1025+
if nwItem and novIdx:
1026+
self.titleLabel.setText("<b>%s</b>" % self.tr(self.LVL_MAP.get(novIdx.level, "H1")))
1027+
self.titleValue.setText(novIdx.title)
10021028

1003-
itemStatus, _ = nwItem.getImportStatus(incIcon=False)
1029+
itemStatus, _ = nwItem.getImportStatus(incIcon=False)
10041030

1005-
self.fileValue.setText(nwItem.itemName)
1006-
self.itemValue.setText(itemStatus)
1031+
self.fileValue.setText(nwItem.itemName)
1032+
self.itemValue.setText(itemStatus)
10071033

1008-
cC = checkInt(novIdx.charCount, 0)
1009-
wC = checkInt(novIdx.wordCount, 0)
1010-
pC = checkInt(novIdx.paraCount, 0)
1034+
self.cCValue.setText(f"{checkInt(novIdx.charCount, 0):n}")
1035+
self.wCValue.setText(f"{checkInt(novIdx.wordCount, 0):n}")
1036+
self.pCValue.setText(f"{checkInt(novIdx.paraCount, 0):n}")
10111037

1012-
self.cCValue.setText(f"{cC:n}")
1013-
self.wCValue.setText(f"{wC:n}")
1014-
self.pCValue.setText(f"{pC:n}")
1038+
self.synopValue.setText(novIdx.synopsis)
10151039

1016-
self.synopValue.setText(novIdx.synopsis)
1040+
self.povKeyValue.setText(self._formatTags(novRefs, nwKeyWords.POV_KEY))
1041+
self.focKeyValue.setText(self._formatTags(novRefs, nwKeyWords.FOCUS_KEY))
1042+
self.chrKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CHAR_KEY))
1043+
self.pltKeyValue.setText(self._formatTags(novRefs, nwKeyWords.PLOT_KEY))
1044+
self.timKeyValue.setText(self._formatTags(novRefs, nwKeyWords.TIME_KEY))
1045+
self.wldKeyValue.setText(self._formatTags(novRefs, nwKeyWords.WORLD_KEY))
1046+
self.objKeyValue.setText(self._formatTags(novRefs, nwKeyWords.OBJECT_KEY))
1047+
self.entKeyValue.setText(self._formatTags(novRefs, nwKeyWords.ENTITY_KEY))
1048+
self.cstKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CUSTOM_KEY))
10171049

1018-
self.povKeyValue.setText(self._formatTags(novRefs, nwKeyWords.POV_KEY))
1019-
self.focKeyValue.setText(self._formatTags(novRefs, nwKeyWords.FOCUS_KEY))
1020-
self.chrKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CHAR_KEY))
1021-
self.pltKeyValue.setText(self._formatTags(novRefs, nwKeyWords.PLOT_KEY))
1022-
self.timKeyValue.setText(self._formatTags(novRefs, nwKeyWords.TIME_KEY))
1023-
self.wldKeyValue.setText(self._formatTags(novRefs, nwKeyWords.WORLD_KEY))
1024-
self.objKeyValue.setText(self._formatTags(novRefs, nwKeyWords.OBJECT_KEY))
1025-
self.entKeyValue.setText(self._formatTags(novRefs, nwKeyWords.ENTITY_KEY))
1026-
self.cstKeyValue.setText(self._formatTags(novRefs, nwKeyWords.CUSTOM_KEY))
1027-
1028-
return True
1050+
return
10291051

10301052
@pyqtSlot()
10311053
def updateClasses(self) -> None:
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"Title","Document","Words","Pars","POV","Characters","Plot","Locations","Synopsis"
2+
"Lorem Ipsum","Lorem Ipsum","40","3","","","","",""
3+
"Prologue","Prologue","92","1","","","","","Explanation from the lipsum.com website."
4+
"Act One","Act One","6","1","","","","",""
5+
"Chapter One","Chapter One","67","1","Bod","","Main","Europe","Lorem ipsum dolor sit amet, consectetur adipiscing elit. Pellentesque at aliquam quam."
6+
"Scene One","Scene One","174","2","Bod","","Main","Europe","Aenean ut placerat velit. Etiam laoreet ullamcorper risus, eget lobortis enim scelerisque non. Suspendisse id maximus nunc, et mollis sapien. Curabitur vel semper sapien, non pulvinar dolor. Etiam finibus nisi vel mi molestie consectetur."
7+
"Scene One, Section Two","Scene One","230","2","","","","",""
8+
"Scene Two","Scene Two","299","3","Bod","","Main","Europe","Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Integer sapien nulla, dictum at lacus a, dignissim consectetur dolor. Nunc vel eleifend lacus, eu dapibus orci."
9+
"Scene Two, Section Two","Scene Two","301","3","","","","",""
10+
"Chapter Two","Chapter Two","70","1","Bod","","Main","Europe","Curabitur a elit posuere, varius ex et, convallis neque. Phasellus sagittis pharetra sem vitae dapibus. Curabitur varius lorem non pulvinar congue."
11+
"Scene Three","Scene Three","439","4","Bod","","Main","Europe","Aenean ut libero ut lectus porttitor rhoncus vel et massa. Nam pretium, nibh et varius vehicula, urna metus blandit eros, euismod pharetra diam diam et libero. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos."
12+
"Scene Four","Scene Four","563","6","Bod","","Main","Europe","Nam tempor blandit magna laoreet aliquet. Vestibulum auctor posuere leo, ac gravida nisi rhoncus varius. Aenean posuere dolor vitae condimentum volutpat. Donec egestas volutpat risus, quis luctus justo."
13+
"Scene Five","Scene Five","543","5","Bod","","Main","Europe","Praesent eget est porta, dictum ante in, egestas risus. Mauris risus mauris, consequat aliquam mauris et, feugiat iaculis ipsum. Aliquam arcu ipsum, fermentum ut arcu sed, lobortis euismod sem. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus."

tests/test_gui/test_gui_guimain.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,7 @@ def testGuiMain_Editing(qtbot, monkeypatch, nwGUI, projPath, tstPaths, mockRnd):
507507
errPos = currPos - 13
508508
if not sys.platform.startswith("win32"):
509509
# Skip on Windows as spell checking is off there
510+
# This check will fail without an 'en' dictionary, like aspell-en
510511
word, cPos, cLen, suggest = docEditor._qDocument.spellErrorAtPos(errPos)
511512
assert word == "tesst"
512513
assert cPos == 15

tests/test_gui/test_gui_outline.py

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@
2323
import time
2424
import pytest
2525

26-
from tools import buildTestProject, writeFile
26+
from shutil import copyfile
27+
28+
from tools import buildTestProject, cmpFiles, writeFile
2729

2830
from PyQt5.QtCore import Qt
29-
from PyQt5.QtWidgets import QWidget, QAction
31+
from PyQt5.QtWidgets import QFileDialog, QWidget, QAction
3032

3133
from novelwriter import CONFIG, SHARED
3234
from novelwriter.enum import nwItemClass, nwOutline, nwView
@@ -72,6 +74,7 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
7274

7375
# Option State
7476
# ============
77+
7578
pOptions = SHARED.project.options
7679
colNames = [h.name for h in nwOutline]
7780
colItems = [h for h in nwOutline]
@@ -156,13 +159,25 @@ def testGuiOutline_Main(qtbot, monkeypatch, nwGUI, projPath):
156159
assert len(hiddenStates) == len(columnState)
157160
assert not any(hiddenStates)
158161

162+
# Move Columns
163+
# ============
164+
165+
# Current Order
166+
order = [outlineTree._colIdx[col] for col in outlineTree._treeOrder]
167+
assert order == [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
168+
169+
# Move 3 to 0
170+
outlineTree._columnMoved(0, 3, 0)
171+
order = [outlineTree._colIdx[col] for col in outlineTree._treeOrder]
172+
assert order == [3, 0, 1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
173+
159174
# qtbot.stop()
160175

161176
# END Test testGuiOutline_Main
162177

163178

164179
@pytest.mark.gui
165-
def testGuiOutline_Content(qtbot, nwGUI, prjLipsum):
180+
def testGuiOutline_Content(qtbot, monkeypatch, nwGUI, prjLipsum, fncPath, tstPaths):
166181
"""Test the outline view."""
167182
assert nwGUI.openProject(prjLipsum)
168183

@@ -234,6 +249,8 @@ def testGuiOutline_Content(qtbot, nwGUI, prjLipsum):
234249
# Scene One
235250
selItem = outlineTree.topLevelItem(4)
236251

252+
outlineTree.clearSelection()
253+
assert outlineTree.getSelectedHandle() == (None, None) # No selection
237254
outlineTree.setCurrentItem(selItem)
238255
tHandle, sTitle = outlineTree.getSelectedHandle()
239256
assert tHandle == "88243afbe5ed8"
@@ -265,6 +282,19 @@ def testGuiOutline_Content(qtbot, nwGUI, prjLipsum):
265282
outlineTree._treeDoubleClick(selItem, 0)
266283
assert nwGUI.docEditor.docHandle == "88243afbe5ed8"
267284

285+
# Dump to CSV
286+
# ===========
287+
with monkeypatch.context() as mp:
288+
csvFile = fncPath / "outline.csv"
289+
mp.setattr(QFileDialog, "getSaveFileName", lambda *a, **k: (str(csvFile), ""))
290+
outlineBar.aExport.trigger()
291+
292+
testFile = tstPaths.outDir / "guiOutline_Content_outline.csv"
293+
compFile = tstPaths.refDir / "guiOutline_Content_outline.csv"
294+
295+
copyfile(csvFile, testFile)
296+
assert cmpFiles(testFile, compFile)
297+
268298
# qtbot.stop()
269299

270300
# END Test testGuiOutline_Content

0 commit comments

Comments
 (0)