""" novelWriter – GUI Writing Statistics ==================================== File History: Created: 2019-10-20 [0.3.0] GuiWritingStats This file is a part of novelWriter Copyright (C) 2019 Veronica Berglyd Olsen and novelWriter contributors This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . """ # noqa from __future__ import annotations import json import logging from datetime import datetime from typing import TYPE_CHECKING from PyQt6.QtCore import Qt, pyqtSlot from PyQt6.QtGui import QAction, QCloseEvent, QCursor, QPixmap from PyQt6.QtWidgets import ( QApplication, QDialogButtonBox, QFileDialog, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QMenu, QSpinBox, QTreeWidget, QTreeWidgetItem ) from novelwriter import CONFIG, SHARED from novelwriter.common import checkInt, checkIntTuple, formatTime, minmax, qtLambda from novelwriter.constants import nwConst from novelwriter.enum import nwStandardButton from novelwriter.error import formatException from novelwriter.extensions.modified import NPushButton, NToolDialog from novelwriter.extensions.switch import NSwitch from novelwriter.types import ( QtAlignLeftMiddle, QtAlignRight, QtAlignRightMiddle, QtDecoration, QtRoleAction, QtRoleDestruct ) if TYPE_CHECKING: from novelwriter.guimain import GuiMain logger = logging.getLogger(__name__) class GuiWritingStats(NToolDialog): """GUI Tools: Writing Statistics. Displays data from the NWSessionLog object. """ C_TIME = 0 C_LENGTH = 1 C_IDLE = 2 C_COUNT = 3 C_BAR = 4 FMT_JSON = 0 FMT_CSV = 1 def __init__(self, parent: GuiMain) -> None: super().__init__(parent=parent) logger.debug("Create: GuiWritingStats") self.setObjectName("GuiWritingStats") self.logData = [] self.filterData = [] self.timeFilter = 0.0 self.wordOffset = 0 pOptions = SHARED.project.options self.setWindowTitle(self.tr("Writing Statistics")) self.setMinimumWidth(420) self.setMinimumHeight(400) self.resize( pOptions.getInt("GuiWritingStats", "winWidth", 550), pOptions.getInt("GuiWritingStats", "winHeight", 500), ) # List Box wCol0 = pOptions.getInt("GuiWritingStats", "widthCol0", 180) wCol1 = pOptions.getInt("GuiWritingStats", "widthCol1", 80) wCol2 = pOptions.getInt("GuiWritingStats", "widthCol2", 80) wCol3 = pOptions.getInt("GuiWritingStats", "widthCol3", 80) self.listBox = QTreeWidget(self) self.listBox.setHeaderLabels([ self.tr("Session Start"), self.tr("Length"), self.tr("Idle"), self.tr("Words"), self.tr("Histogram"), ]) self.listBox.setIndentation(0) self.listBox.setColumnWidth(self.C_TIME, wCol0) self.listBox.setColumnWidth(self.C_LENGTH, wCol1) self.listBox.setColumnWidth(self.C_IDLE, wCol2) self.listBox.setColumnWidth(self.C_COUNT, wCol3) hHeader = self.listBox.headerItem() if hHeader is not None: hHeader.setTextAlignment(self.C_LENGTH, QtAlignRight) hHeader.setTextAlignment(self.C_IDLE, QtAlignRight) hHeader.setTextAlignment(self.C_COUNT, QtAlignRight) sortCol = minmax(pOptions.getInt("GuiWritingStats", "sortCol", 0), 0, 2) sortOrder = checkIntTuple( pOptions.getInt("GuiWritingStats", "sortOrder", 1), (0, 1), 1 ) sortOrders = (Qt.SortOrder.AscendingOrder, Qt.SortOrder.DescendingOrder) self.listBox.sortByColumn(sortCol, sortOrders[sortOrder]) self.listBox.setSortingEnabled(True) # Word Bar self.barHeight = round(0.5*SHARED.theme.fontPixelSize) self.barImage = QPixmap(self.barHeight, self.barHeight) self.barImage.fill(self.palette().highlight().color()) # Session Info self.infoBox = QGroupBox(self.tr("Sum Totals"), self) self.infoForm = QGridLayout(self) self.infoBox.setLayout(self.infoForm) self.labelTotal = QLabel(formatTime(0), self) self.labelTotal.setFont(SHARED.theme.guiFontFixed) self.labelTotal.setAlignment(QtAlignRightMiddle) self.labelIdleT = QLabel(formatTime(0), self) self.labelIdleT.setFont(SHARED.theme.guiFontFixed) self.labelIdleT.setAlignment(QtAlignRightMiddle) self.labelFilter = QLabel(formatTime(0), self) self.labelFilter.setFont(SHARED.theme.guiFontFixed) self.labelFilter.setAlignment(QtAlignRightMiddle) self.novelWords = QLabel("0", self) self.novelWords.setFont(SHARED.theme.guiFontFixed) self.novelWords.setAlignment(QtAlignRightMiddle) self.notesWords = QLabel("0", self) self.notesWords.setFont(SHARED.theme.guiFontFixed) self.notesWords.setAlignment(QtAlignRightMiddle) self.totalWords = QLabel("0", self) self.totalWords.setFont(SHARED.theme.guiFontFixed) self.totalWords.setAlignment(QtAlignRightMiddle) lblTTime = QLabel(self.tr("Total Time:"), self) lblITime = QLabel(self.tr("Idle Time:"), self) lblFTime = QLabel(self.tr("Filtered Time:"), self) lblNvCount = QLabel(self.tr("Novel Word Count:"), self) lblNtCount = QLabel(self.tr("Notes Word Count:"), self) lblTtCount = QLabel(self.tr("Total Word Count:"), self) self.infoForm.addWidget(lblTTime, 0, 0) self.infoForm.addWidget(lblITime, 1, 0) self.infoForm.addWidget(lblFTime, 2, 0) self.infoForm.addWidget(lblNvCount, 3, 0) self.infoForm.addWidget(lblNtCount, 4, 0) self.infoForm.addWidget(lblTtCount, 5, 0) self.infoForm.addWidget(self.labelTotal, 0, 1) self.infoForm.addWidget(self.labelIdleT, 1, 1) self.infoForm.addWidget(self.labelFilter, 2, 1) self.infoForm.addWidget(self.novelWords, 3, 1) self.infoForm.addWidget(self.notesWords, 4, 1) self.infoForm.addWidget(self.totalWords, 5, 1) self.infoForm.setRowStretch(6, 1) # Filter Options iPx = SHARED.theme.baseIconHeight bSz = SHARED.theme.buttonIconSize self.filterForm = QGridLayout(self) self.filterForm.setRowStretch(6, 1) self.filterBox = QGroupBox(self.tr("Filters"), self) self.filterBox.setLayout(self.filterForm) # Include Novel Files self.swtIncNovel = NSwitch(self, height=iPx) self.swtIncNovel.setChecked( pOptions.getBool("GuiWritingStats", "incNovel", True) ) self.swtIncNovel.clicked.connect(self._updateListBox) self.lblIncNovel = QLabel(self.tr("Count novel files"), self) self.lblIncNovel.setBuddy(self.swtIncNovel) self.filterForm.addWidget(self.lblIncNovel, 0, 0) self.filterForm.addWidget(self.swtIncNovel, 0, 1) # Include Note Files self.swtIncNotes = NSwitch(self, height=iPx) self.swtIncNotes.setChecked( pOptions.getBool("GuiWritingStats", "incNotes", True) ) self.swtIncNotes.clicked.connect(self._updateListBox) self.lblIncNotes = QLabel(self.tr("Count note files"), self) self.lblIncNotes.setBuddy(self.swtIncNotes) self.filterForm.addWidget(self.lblIncNotes, 1, 0) self.filterForm.addWidget(self.swtIncNotes, 1, 1) # Hide Zero Counts self.swtHideZeros = NSwitch(self, height=iPx) self.swtHideZeros.setChecked( pOptions.getBool("GuiWritingStats", "hideZeros", True) ) self.swtHideZeros.clicked.connect(self._updateListBox) self.lblHideZeros = QLabel(self.tr("Hide zero word count"), self) self.lblHideZeros.setBuddy(self.swtHideZeros) self.filterForm.addWidget(self.lblHideZeros, 2, 0) self.filterForm.addWidget(self.swtHideZeros, 2, 1) # Hide Negative Counts self.swtHideNegative = NSwitch(self, height=iPx) self.swtHideNegative.setChecked( pOptions.getBool("GuiWritingStats", "hideNegative", False) ) self.swtHideNegative.clicked.connect(self._updateListBox) self.lblHideNegative = QLabel(self.tr("Hide negative word count"), self) self.lblHideNegative.setBuddy(self.swtHideNegative) self.filterForm.addWidget(self.lblHideNegative, 3, 0) self.filterForm.addWidget(self.swtHideNegative, 3, 1) # Group Entries self.swtGroupByDay = NSwitch(self, height=iPx) self.swtGroupByDay.setChecked( pOptions.getBool("GuiWritingStats", "groupByDay", False) ) self.swtGroupByDay.clicked.connect(self._updateListBox) self.lblGroupByDay = QLabel(self.tr("Group entries by day"), self) self.lblGroupByDay.setBuddy(self.swtGroupByDay) self.filterForm.addWidget(self.lblGroupByDay, 4, 0) self.filterForm.addWidget(self.swtGroupByDay, 4, 1) # Show Idle self.swtShowIdleTime = NSwitch(self, height=iPx) self.swtShowIdleTime.setChecked( pOptions.getBool("GuiWritingStats", "showIdleTime", False) ) self.swtShowIdleTime.clicked.connect(self._updateListBox) self.lblShowIdleTime = QLabel(self.tr("Show idle time"), self) self.lblShowIdleTime.setBuddy(self.swtShowIdleTime) self.filterForm.addWidget(self.lblShowIdleTime, 5, 0) self.filterForm.addWidget(self.swtShowIdleTime, 5, 1) # Settings self.histMax = QSpinBox(self) self.histMax.setMinimum(100) self.histMax.setMaximum(100000) self.histMax.setSingleStep(100) self.histMax.setValue( pOptions.getInt("GuiWritingStats", "histMax", 2000) ) self.histMax.valueChanged.connect(self._updateListBox) self.optsBox = QHBoxLayout() self.optsBox.addStretch(1) self.optsBox.addWidget(QLabel(self.tr("Word count cap for the histogram"), self), 0) self.optsBox.addWidget(self.histMax, 0) # Buttons self.btnClose = SHARED.theme.getStandardButton(nwStandardButton.CLOSE, self) self.btnClose.clicked.connect(self._doClose) self.btnClose.setAutoDefault(False) self.saveJSON = QAction(self.tr("JSON Data File (.json)"), self) self.saveJSON.triggered.connect(qtLambda(self._saveData, self.FMT_JSON)) self.saveCSV = QAction(self.tr("CSV Data File (.csv)"), self) self.saveCSV.triggered.connect(qtLambda(self._saveData, self.FMT_CSV)) self.saveMenu = QMenu(self) self.saveMenu.addAction(self.saveJSON) self.saveMenu.addAction(self.saveCSV) self.btnSave = NPushButton(self, self.tr("Save As"), bSz, "btn_save", "blue") self.btnSave.setAutoDefault(False) self.btnSave.setMenu(self.saveMenu) self.btnBox = QDialogButtonBox(self) self.btnBox.addButton(self.btnSave, QtRoleAction) self.btnBox.addButton(self.btnClose, QtRoleDestruct) # Assemble self.outerBox = QGridLayout() self.outerBox.addWidget(self.listBox, 0, 0, 1, 2) self.outerBox.addLayout(self.optsBox, 1, 0, 1, 2) self.outerBox.addWidget(self.infoBox, 2, 0) self.outerBox.addWidget(self.filterBox, 2, 1) self.outerBox.addWidget(self.btnBox, 3, 0, 1, 2) self.outerBox.setRowStretch(0, 1) self.setLayout(self.outerBox) logger.debug("Ready: GuiWritingStats") def __del__(self) -> None: # pragma: no cover logger.debug("Delete: GuiWritingStats") def populateGUI(self) -> None: """Populate list box with data from the log file.""" QApplication.setOverrideCursor(QCursor(Qt.CursorShape.WaitCursor)) self._loadLogFile() self._updateListBox() QApplication.restoreOverrideCursor() ## # Events ## def closeEvent(self, event: QCloseEvent) -> None: """Capture the user closing the window.""" event.accept() self.softDelete() ## # Private Slots ## @pyqtSlot() def _doClose(self) -> None: """Save the state of the window, clear cache, end close.""" self.logData = [] header = self.listBox.header() sortCol = self.listBox.sortColumn() sortOrder = header.sortIndicatorOrder() if header else 0 incNovel = self.swtIncNovel.isChecked() incNotes = self.swtIncNotes.isChecked() hideZeros = self.swtHideZeros.isChecked() hideNegative = self.swtHideNegative.isChecked() groupByDay = self.swtGroupByDay.isChecked() showIdleTime = self.swtShowIdleTime.isChecked() histMax = self.histMax.value() logger.debug("Saving State: GuiWritingStats") pOptions = SHARED.project.options pOptions.setValue("GuiWritingStats", "winWidth", self.width()) pOptions.setValue("GuiWritingStats", "winHeight", self.height()) pOptions.setValue("GuiWritingStats", "widthCol0", self.listBox.columnWidth(0)) pOptions.setValue("GuiWritingStats", "widthCol1", self.listBox.columnWidth(1)) pOptions.setValue("GuiWritingStats", "widthCol2", self.listBox.columnWidth(2)) pOptions.setValue("GuiWritingStats", "widthCol3", self.listBox.columnWidth(3)) pOptions.setValue("GuiWritingStats", "sortCol", sortCol) pOptions.setValue("GuiWritingStats", "sortOrder", sortOrder) pOptions.setValue("GuiWritingStats", "incNovel", incNovel) pOptions.setValue("GuiWritingStats", "incNotes", incNotes) pOptions.setValue("GuiWritingStats", "hideZeros", hideZeros) pOptions.setValue("GuiWritingStats", "hideNegative", hideNegative) pOptions.setValue("GuiWritingStats", "groupByDay", groupByDay) pOptions.setValue("GuiWritingStats", "showIdleTime", showIdleTime) pOptions.setValue("GuiWritingStats", "histMax", histMax) pOptions.saveSettings() self.close() def _saveData(self, dataFmt: int) -> bool: """Save the content of the list box to a file.""" fileExt = "" textFmt = "" if dataFmt == self.FMT_JSON: fileExt = "json" textFmt = self.tr("JSON Data File") elif dataFmt == self.FMT_CSV: fileExt = "csv" textFmt = self.tr("CSV Data File") else: return False # Generate the file name savePath = CONFIG.lastPath("stats") / f"sessionStats.{fileExt}" savePath, _ = QFileDialog.getSaveFileName( self, self.tr("Save Data As"), str(savePath), f"{textFmt} (*.{fileExt})" ) if not savePath: return False CONFIG.setLastPath("stats", savePath) # Do the actual writing wSuccess = False errMsg = "" try: with open(savePath, mode="w", encoding="utf-8") as outFile: if dataFmt == self.FMT_JSON: jsonData = [] for _, sD, tT, wD, wA, wB, tI in self.filterData: jsonData.append({ "date": sD, "length": tT, "newWords": wD, "novelWords": wA, "noteWords": wB, "idleTime": tI, }) json.dump(jsonData, outFile, indent=2) wSuccess = True if dataFmt == self.FMT_CSV: outFile.write( '"Date","Length (sec)","Words Changed",' '"Novel Words","Note Words","Idle Time (sec)"\n' ) for _, sD, tT, wD, wA, wB, tI in self.filterData: outFile.write(f'"{sD}",{tT:.0f},{wD},{wA},{wB},{tI}\n') wSuccess = True except Exception as exc: errMsg = formatException(exc) wSuccess = False # Report to user if wSuccess: SHARED.info( self.tr("{0} file successfully written to:").format(textFmt), info=savePath ) else: SHARED.error( self.tr("Failed to write {0} file.").format(textFmt), info=errMsg ) return wSuccess ## # Internal Functions ## def _loadLogFile(self) -> None: """Load the content of the log file into a buffer.""" logger.debug("Loading session log file") self.logData = [] self.wordOffset = 0 ttNovel = 0 ttNotes = 0 ttTime = 0 ttIdle = 0 for record in SHARED.project.session.iterRecords(): rType = record.get("type") if rType == "initial": self.wordOffset = checkInt(record.get("offset"), 0) logger.debug("Initial word count when log was started is %d", self.wordOffset) elif rType == "record": try: dStart = datetime.fromisoformat(str(record.get("start"))) dEnd = datetime.fromisoformat(str(record.get("end"))) except Exception: logger.error("Invalid session log record") continue wcNovel = max(checkInt(record.get("novel"), 0), 0) wcNotes = max(checkInt(record.get("notes"), 0), 0) sIdle = max(checkInt(record.get("idle"), 0), 0) tDiff = dEnd - dStart sDiff = tDiff.total_seconds() ttTime += sDiff ttIdle += sIdle ttNovel = wcNovel ttNotes = wcNotes self.logData.append((dStart, sDiff, wcNovel, wcNotes, sIdle)) ttWords = ttNovel + ttNotes self.labelTotal.setText(formatTime(round(ttTime))) self.labelIdleT.setText(formatTime(round(ttIdle))) self.novelWords.setText(f"{ttNovel:n}") self.notesWords.setText(f"{ttNotes:n}") self.totalWords.setText(f"{ttWords:n}") ## # Private Slots ## @pyqtSlot() def _updateListBox(self) -> None: """Load/reload the content of the list box.""" self.listBox.clear() self.timeFilter = 0.0 incNovel = self.swtIncNovel.isChecked() incNotes = self.swtIncNotes.isChecked() hideZeros = self.swtHideZeros.isChecked() hideNegative = self.swtHideNegative.isChecked() groupByDay = self.swtGroupByDay.isChecked() histMax = self.histMax.value() # Group the data if groupByDay: tempData = [] sessDate = None sessTime = 0 sessIdle = 0 lstNovel = 0 lstNotes = 0 for n, (dStart, sDiff, wcNovel, wcNotes, sIdle) in enumerate(self.logData): if n == 0: sessDate = dStart.date() if sessDate != dStart.date(): tempData.append((sessDate, sessTime, lstNovel, lstNotes, sessIdle)) sessDate = dStart.date() sessTime = sDiff sessIdle = sIdle lstNovel = wcNovel lstNotes = wcNotes else: sessTime += sDiff sessIdle += sIdle lstNovel = wcNovel lstNotes = wcNotes if sessDate is not None: tempData.append((sessDate, sessTime, lstNovel, lstNotes, sessIdle)) else: tempData = self.logData # Calculate Word Diff self.filterData = [] pcTotal = 0 listMax = 0 isFirst = True for dStart, sDiff, wcNovel, wcNotes, sIdle in tempData: wcTotal = 0 if incNovel: wcTotal += wcNovel if incNotes: wcTotal += wcNotes dwTotal = wcTotal - pcTotal if hideZeros and dwTotal == 0: continue if hideNegative and dwTotal < 0: pcTotal = wcTotal continue if isFirst: # Subtract the offset from the first list entry dwTotal -= self.wordOffset dwTotal = max(dwTotal, 1) # Don't go zero or negative isFirst = False if groupByDay: sStart = dStart.strftime(nwConst.FMT_DSTAMP) else: sStart = dStart.strftime(nwConst.FMT_TSTAMP) self.filterData.append((dStart, sStart, sDiff, dwTotal, wcNovel, wcNotes, sIdle)) listMax = min(max(listMax, dwTotal), histMax) pcTotal = wcTotal # Populate the list mTrans = Qt.TransformationMode.FastTransformation mAspect = Qt.AspectRatioMode.IgnoreAspectRatio showIdleTime = self.swtShowIdleTime.isChecked() for _, sStart, sDiff, nWords, _, _, sIdle in self.filterData: if showIdleTime: idleEntry = formatTime(sIdle) else: sRatio = sIdle/sDiff if sDiff > 0.0 else 0.0 idleEntry = f"{round(100.0 * sRatio)} %" newItem = QTreeWidgetItem() newItem.setText(self.C_TIME, sStart) newItem.setText(self.C_LENGTH, formatTime(round(sDiff))) newItem.setText(self.C_IDLE, idleEntry) newItem.setText(self.C_COUNT, f"{nWords:n}") if nWords > 0 and listMax > 0: wBar = self.barImage.scaled( int(200*min(nWords, histMax)/listMax), self.barHeight, mAspect, mTrans ) newItem.setData(self.C_BAR, QtDecoration, wBar) newItem.setTextAlignment(self.C_LENGTH, QtAlignRight) newItem.setTextAlignment(self.C_IDLE, QtAlignRight) newItem.setTextAlignment(self.C_COUNT, QtAlignRight) newItem.setTextAlignment(self.C_BAR, QtAlignLeftMiddle) newItem.setFont(self.C_TIME, SHARED.theme.guiFontFixed) newItem.setFont(self.C_LENGTH, SHARED.theme.guiFontFixed) newItem.setFont(self.C_COUNT, SHARED.theme.guiFontFixed) newItem.setFont(self.C_IDLE, SHARED.theme.guiFontFixed) self.listBox.addTopLevelItem(newItem) self.timeFilter += sDiff self.labelFilter.setText(formatTime(round(self.timeFilter)))