diff --git a/novelwriter/constants.py b/novelwriter/constants.py index c06fb4c0..4a45f051 100644 --- a/novelwriter/constants.py +++ b/novelwriter/constants.py @@ -91,6 +91,7 @@ class nwFiles: OPTS_FILE = "options.json" PROJ_DICT = "wordlist.txt" SESS_STATS = "sessionStats.log" + SESS_FILE = "sessions.jsonl" # END Class nwFiles diff --git a/novelwriter/core/coretools.py b/novelwriter/core/coretools.py index 627e6f75..d669586b 100644 --- a/novelwriter/core/coretools.py +++ b/novelwriter/core/coretools.py @@ -1,7 +1,6 @@ """ novelWriter – Project Document Tools ==================================== -A collection of tools to create and manipulate documents File History: Created: 2022-10-02 [2.0rc1] DocMerger @@ -28,7 +27,6 @@ along with this program. If not, see . import shutil import logging -from time import time from functools import partial from PyQt5.QtCore import QCoreApplication @@ -319,7 +317,7 @@ class ProjectBuilder: project.data.setTitle(projTitle) project.data.setAuthor(projAuthor) project.setDefaultStatusImport() - project._projOpened = int(time()) + project.session.startSession() # Add Root Folders hNovelRoot = project.newRoot(nwItemClass.NOVEL) diff --git a/novelwriter/core/project.py b/novelwriter/core/project.py index badeba21..f4c9e454 100644 --- a/novelwriter/core/project.py +++ b/novelwriter/core/project.py @@ -22,6 +22,7 @@ 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 . """ +from __future__ import annotations import json import logging @@ -33,9 +34,10 @@ from functools import partial from PyQt5.QtCore import QCoreApplication, QObject, pyqtSignal from novelwriter import CONFIG, __version__, __hexversion__ +from novelwriter.core.sessions import NWSessionLog from novelwriter.enum import nwItemType, nwItemClass, nwItemLayout, nwAlert from novelwriter.error import logException -from novelwriter.constants import trConst, nwFiles, nwLabels +from novelwriter.constants import trConst, nwLabels from novelwriter.core.tree import NWTree from novelwriter.core.item import NWItem from novelwriter.core.index import NWIndex @@ -66,12 +68,12 @@ class NWProject(QObject): self._data = NWProjectData(self) # The project settings self._tree = NWTree(self) # The project tree self._index = NWIndex(self) # The projecty index + self._session = NWSessionLog(self) # The session record # Data Cache self._langData = {} # Localisation data # Project Status - self._projOpened = 0 # The time stamp of when the project file was opened self._projChanged = False # The project has unsaved changes self._lockedBy = None # Data on which computer has the project open self._projFiles = [] # A list of all files in the content folder on load @@ -108,9 +110,13 @@ class NWProject(QObject): def index(self): return self._index + @property + def session(self) -> NWSessionLog: + return self._session + @property def projOpened(self): - return self._projOpened + return self._session.start @property def projChanged(self): @@ -228,7 +234,6 @@ class NWProject(QObject): default values. """ # Project Status - self._projOpened = 0 self._projChanged = False # Project Tree @@ -236,6 +241,7 @@ class NWProject(QObject): self._tree.clear() self._index.clearIndex() self._data = NWProjectData(self) + self._session = NWSessionLog(self) # Project Settings self._projFiles = [] @@ -370,8 +376,7 @@ class NWProject(QObject): self._index.rebuildIndex() self.updateWordCounts() - self._projOpened = time() - + self._session.startSession() self._storage.writeLockFile() self.setProjectChanged(False) self.mainGui.setStatus(self.tr("Opened Project: {0}").format(self._data.name)) @@ -407,7 +412,7 @@ class NWProject(QObject): return False saveTime = time() - editTime = int(self._data.editTime + saveTime - self._projOpened) + editTime = self._data.editTime + max(round(saveTime - self._session.start), 0) content = self._tree.pack() if not xmlWriter.write(self._data, content, saveTime, editTime): self.mainGui.makeAlert(self.tr( @@ -437,7 +442,7 @@ class NWProject(QObject): logger.info("Closing project") self._options.saveSettings() self._tree.writeToCFile() - self._appendSessionStats(idleTime) + self._session.appendSession(idleTime) self._storage.clearLockFile() self._storage.closeSession() self.clearProject() @@ -560,18 +565,17 @@ class NWProject(QObject): # Getters ## - def getLockStatus(self): - """Return the project lock information for the project. - """ + def getLockStatus(self) -> list | None: + """Return the project lock information for the project.""" if isinstance(self._lockedBy, list) and len(self._lockedBy) == 4: return self._lockedBy return None - def getCurrentEditTime(self): + def getCurrentEditTime(self) -> int: """Get the total project edit time, including the time spent in the current session. """ - return round(self._data.editTime + time() - self._projOpened) + return self._data.editTime + round(time() - self._session.start) def getProjectItems(self): """This function ensures that the item tree loaded is sent to @@ -798,49 +802,4 @@ class NWProject(QObject): return True - def _appendSessionStats(self, idleTime): - """Append session statistics to the sessions log file. - """ - sessionFile = self._storage.getMetaFile(nwFiles.SESS_STATS) - if not isinstance(sessionFile, Path): - return False - - nowTime = time() - iNovel, iNotes = self._data.initCounts - cNovel, cNotes = self._data.currCounts - iTotal = iNovel + iNotes - sessDiff = cNovel + cNotes - iTotal - sessTime = nowTime - self._projOpened - - logger.info("The session lasted %d sec and added %d words", int(sessTime), sessDiff) - if sessTime < 300 and sessDiff == 0: - logger.info("Session too short, skipping log entry") - return False - - try: - isFile = sessionFile.exists() # We must save the state before we open - with open(sessionFile, mode="a+", encoding="utf-8") as outFile: - if not isFile: - # It's a new file, so add a header - if iTotal > 0: - outFile.write("# Offset %d\n" % iTotal) - outFile.write("# %-17s %-19s %8s %8s %8s\n" % ( - "Start Time", "End Time", "Novel", "Notes", "Idle" - )) - - outFile.write("%-19s %-19s %8d %8d %8d\n" % ( - formatTimeStamp(self._projOpened), - formatTimeStamp(nowTime), - cNovel, - cNotes, - int(idleTime), - )) - - except Exception: - logger.error("Failed to write session stats file") - logException() - return False - - return True - # END Class NWProject diff --git a/novelwriter/core/sessions.py b/novelwriter/core/sessions.py new file mode 100644 index 00000000..a071e0fb --- /dev/null +++ b/novelwriter/core/sessions.py @@ -0,0 +1,125 @@ +""" +novelWriter – Project Session Log Class +======================================= + +File History: +Created: 2023-06-11 [2.1b1] + +This file is a part of novelWriter +Copyright 2018–2023, Veronica Berglyd Olsen + +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 . +""" +from __future__ import annotations + +import json +import logging + +from time import time +from typing import TYPE_CHECKING +from pathlib import Path + +from novelwriter.error import logException +from novelwriter.common import formatTimeStamp +from novelwriter.constants import nwFiles + +if TYPE_CHECKING: + from novelwriter.core.project import NWProject + +logger = logging.getLogger(__name__) + + +class NWSessionLog: + """Core: Session JSON Lines Log File + + The class that wraps the session log file, which is in JSON Lines + format. That is, one JSON object per line. + """ + + def __init__(self, project: NWProject): + self._project = project + self._start = 0.0 + return + + ## + # Properties + ## + + @property + def start(self) -> float: + """The session start time.""" + return self._start + + ## + # Methods + ## + + def startSession(self): + """Start the writng session.""" + self._start = time() + return + + def appendSession(self, idleTime: float) -> bool: + """Append session statistics to the sessions log file.""" + sessFile = self._project.storage.getMetaFile(nwFiles.SESS_FILE) + if not isinstance(sessFile, Path): + return False + + now = time() + iNovel, iNotes = self._project.data.initCounts + cNovel, cNotes = self._project.data.currCounts + iTotal = iNovel + iNotes + wDiff = cNovel + cNotes - iTotal + sTime = now - self._start + + logger.info("The session lasted %d sec and added %d words", int(sTime), wDiff) + if sTime < 300 and wDiff == 0: + logger.info("Session too short, skipping log entry") + return False + + try: + if not sessFile.exists(): + with open(sessFile, mode="w", encoding="utf-8") as fObj: + fObj.write(self.createInitial(iTotal)) + + with open(sessFile, mode="a+", encoding="utf-8") as fObj: + fObj.write(self.createRecord( + start=formatTimeStamp(self._start), + end=formatTimeStamp(now), + novel=cNovel, + notes=cNotes, + idle=round(idleTime) + )) + + except Exception: + logger.error("Failed to write to session stats file") + logException() + return False + + return True + + def createInitial(self, total: int) -> str: + """Low level function to create the initial log file record.""" + data = json.dumps({"type": "initial", "offset": total}) + return f"{data}\n" + + def createRecord(self, start: str, end: str, novel: int, notes: int, idle: int) -> str: + """Low level function to create a log record.""" + data = json.dumps({ + "type": "record", "start": start, "end": end, + "novel": novel, "notes": notes, "idle": idle, + }) + return f"{data}\n" + +# END Class NWSessionLog diff --git a/novelwriter/core/storage.py b/novelwriter/core/storage.py index 7bbc5000..78d8d666 100644 --- a/novelwriter/core/storage.py +++ b/novelwriter/core/storage.py @@ -254,7 +254,7 @@ class NWStorage: (baseMeta / nwFiles.INDEX_FILE, f"meta/{nwFiles.INDEX_FILE}"), (baseMeta / nwFiles.OPTS_FILE, f"meta/{nwFiles.OPTS_FILE}"), (baseMeta / nwFiles.PROJ_DICT, f"meta/{nwFiles.PROJ_DICT}"), - (baseMeta / nwFiles.SESS_STATS, f"meta/{nwFiles.SESS_STATS}"), + (baseMeta / nwFiles.SESS_FILE, f"meta/{nwFiles.SESS_FILE}"), ] for contItem in baseCont.iterdir(): name = contItem.name @@ -374,6 +374,10 @@ class NWStorage: def _deprecatedFiles(self, path: Path): """Handle files that are no longer used by novelWriter.""" + sessLog = path / "meta" / "sessionStats.log" + if sessLog.is_file(): + self._convertOldLogFile(sessLog, path / "meta" / nwFiles.SESS_FILE) + remove = [ path / "meta" / "tagsIndex.json", # Renamed in 2.1 Beta 1 path / "meta" / "mainOptions.json", # Replaced in 0.5 @@ -409,4 +413,45 @@ class NWStorage: return + def _convertOldLogFile(self, sessLog: Path, sessJson: Path) -> bool: + """Convert the old text log file format to the new JSON Lines + format. + """ + if sessJson.exists() or not sessLog.exists(): + # If the new file already exists, we won't overwrite it + return True + + try: + data = [] + offset = 0 + session = self._project.session + with open(sessLog, mode="r", encoding="utf-8") as fObj: + for record in fObj: + bits = record.split() + nBits = len(bits) + if record.startswith("# Offset") and nBits == 3: + offset = int(bits[2]) + elif not record.startswith("#") and nBits > 5: + data.append(session.createRecord( + start=f"{bits[0]} {bits[1]}", + end=f"{bits[2]} {bits[3]}", + novel=int(bits[4]), + notes=int(bits[5]), + idle=int(bits[6]) if nBits > 6 else -1, + )) + + with open(sessJson, mode="a+", encoding="utf-8") as fObj: + fObj.write(session.createInitial(offset)) + fObj.write("".join(data)) + + # If we're here, we remove the old file + sessLog.unlink() + + except Exception: + logger.error("Failed to convert old stats file") + logException() + return False + + return True + # END Class NWStorage diff --git a/tests/test_core/test_core_project.py b/tests/test_core/test_core_project.py index 05e4e6cc..39a33517 100644 --- a/tests/test_core/test_core_project.py +++ b/tests/test_core/test_core_project.py @@ -487,7 +487,7 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): # Edit Time theProject.data.setEditTime(1234) - theProject._projOpened = 1600000000 + theProject._session._start = 1600000000 with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) assert theProject.getCurrentEditTime() == 6834 @@ -585,32 +585,32 @@ def testCoreProject_Methods(monkeypatch, mockGUI, fncPath, mockRnd): # No path for writing with monkeypatch.context() as mp: mp.setattr("novelwriter.core.storage.NWStorage.getMetaFile", lambda *a: None) - assert theProject._appendSessionStats(idleTime=0) is False + assert theProject.session.appendSession(idleTime=0) is False # Block open with monkeypatch.context() as mp: mp.setattr("builtins.open", causeOSError) - assert theProject._appendSessionStats(idleTime=0) is False + assert theProject.session.appendSession(idleTime=0) is False # Session too short - theProject._projOpened = time() + theProject._session._start = time() theProject.data.setInitCounts(50, 50) theProject.data.setCurrCounts(50, 50) - assert theProject._appendSessionStats(idleTime=0) is False + assert theProject.session.appendSession(idleTime=0) is False # Write entry - statsFile = theProject.storage.getMetaFile(nwFiles.SESS_STATS) + statsFile = theProject.storage.getMetaFile(nwFiles.SESS_FILE) assert isinstance(statsFile, Path) if statsFile.exists(): statsFile.unlink() - theProject._projOpened = 1600002000 + theProject._session._start = 1600002000 theProject.data._initCounts = [50, 50] theProject.data._currCounts = [200, 100] with monkeypatch.context() as mp: mp.setattr("novelwriter.core.project.time", lambda: 1600005600) - assert theProject._appendSessionStats(idleTime=99) + assert theProject.session.appendSession(idleTime=99) assert statsFile.read_text(encoding="utf-8") == ( "# Offset 100\n" diff --git a/tests/tools.py b/tests/tools.py index 7463e3a1..9951ebc6 100644 --- a/tests/tools.py +++ b/tests/tools.py @@ -19,7 +19,6 @@ You should have received a copy of the GNU General Public License along with this program. If not, see . """ -import time import shutil from pathlib import Path @@ -201,7 +200,7 @@ def buildTestProject(theObject, projPath): aDoc.writeDocument("### %s\n\n" % theProject.tr("New Scene")) theProject.index.reIndexHandle(xHandle[8]) - theProject._projOpened = time.time() + theProject.session.startSession() theProject.setProjectChanged(True) theProject.saveProject(autoSave=True)