Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions novelwriter/core/projectdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,24 +351,26 @@ def setDailyTarget(self, value: Any, auto: Any) -> None:
self._dailyGoalAuto = checkBool(auto, False)
self._project.setProjectChanged(True)

def setDailyTargetCurrent(self, value: Any, date: Any) -> None:
def setDailyTargetCurrent(self, value: Any, last: Any) -> None:
"""Set the current daily goal."""
self._dailyLastCount = checkInt(value, self._dailyLastCount)
self._dailyLastDate = checkDateNone(date, None)
if (lastDate := checkDateNone(last, None)) == date.today():
self._dailyLastCount = checkInt(value, self._dailyLastCount)
self._dailyLastDate = lastDate
else:
self._dailyLastCount = 0
self._dailyLastDate = date.today()

def setDailyProgress(self, count: int) -> None:
"""Set the current daily goal progress."""
if self._dailyLastDate != date.today():
# The date has changed since the last update, either because
# this is the first update ever, or because the clock has
# ticked over to a new day. Roll the last count back to what
# it was at the start of the new day.
# The date has changed since the last update or was not set.
# In either case, we shift to a new day with new counts.
self._dailyLastCount -= self._dailyProgress
self._dailyLastDate = date.today()

initial = self._initCounts[0] - self._dailyLastCount
self._dailyProgress = count - initial
self._remainingWordCount = self._targetWordCount - initial
offset = self._initCounts[0] - self._dailyLastCount
self._dailyProgress = count - offset
self._remainingWordCount = self._targetWordCount - offset

def setLanguage(self, value: str | None) -> None:
"""Set the project language."""
Expand Down
10 changes: 9 additions & 1 deletion novelwriter/editor/editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -1412,6 +1412,14 @@ def inputMethodQuery(self, query: Qt.InputMethodQuery) -> QRect | QVariant:
return variant
return super().inputMethodQuery(query)

def createMimeDataFromSelection(self) -> QMimeData:
"""Overload mime data creation for copy/cut so that only plain
text is put on the clipboard, and not also HTML.
"""
data = QMimeData()
data.setText(self.getSelectedText())
return data

def insertFromMimeData(self, source: QMimeData | None) -> None:
"""Overload mime data insertion in the document."""
if not source:
Expand All @@ -1430,7 +1438,7 @@ def insertFromMimeData(self, source: QMimeData | None) -> None:
document.setMarkdown(data)

if document is not None:
text = FromQTextDocument(document).convertText().strip()
text = FromQTextDocument(document).convertText().strip("\n")
elif source.hasText():
text = source.text()
else:
Expand Down
39 changes: 39 additions & 0 deletions tests/core/test_projectdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,44 @@ def testProjectData_AutoReplace(mockGUI):
assert data.autoReplace == {"A": "B"}


@pytest.mark.core
def testProjectData_DailyTargetCurrent(monkeypatch, mockGUI):
"""Test the setDailyTargetCurrent setter. A stored reference count
must only be restored when it was recorded on the same day; a count
from any other day is stale and must be discarded so it cannot leak
into today's progress calculation.
"""
monkeypatch.setattr(projectdata, "date", _FakeDate)
_FakeDate._today = date(2026, 7, 22)

project = MockProject()
data = ProjectData(project) # type: ignore

# A record from the same day is restored as-is
data.setDailyTargetCurrent(60, "2026-07-22")
assert data.dailyLastCount == 60
assert data._dailyLastDate == date(2026, 7, 22)

# A record from an earlier day is stale and discarded, and the date
# is bumped to today so it isn't mistaken for a fresh record later
data.setDailyTargetCurrent(100, "2026-07-20")
assert data.dailyLastCount == 0
assert data._dailyLastDate == date(2026, 7, 22)

# A missing/invalid date is treated the same as a stale record
data.setDailyTargetCurrent(100, None)
assert data.dailyLastCount == 0
assert data._dailyLastDate == date(2026, 7, 22)

# End-to-end: a project file with a daily target set two days ago is
# loaded. The stale count must not offset today's progress
data = ProjectData(project) # type: ignore
data.setInitCounts(wNovel=500)
data.setDailyTargetCurrent(100, "2026-07-20")
data.setDailyProgress(500)
assert data.dailyProgress == 0


@pytest.mark.core
def testProjectData_DailyProgress(monkeypatch, mockGUI):
"""Test the setDailyProgress setter. The daily progress and the
Expand All @@ -156,6 +194,7 @@ def testProjectData_DailyProgress(monkeypatch, mockGUI):
new day.
"""
monkeypatch.setattr(projectdata, "date", _FakeDate)
_FakeDate._today = date(2026, 7, 20)

project = MockProject()
data = ProjectData(project) # type: ignore
Expand Down
5 changes: 4 additions & 1 deletion tests/core/test_projectxml.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,10 @@ def testProjectXMLReader_ReadCurrent(monkeypatch, mockGUI, tstPaths, fncPath):
assert data.targetDeadline == date.fromisoformat("2026-08-20")
assert data.dailyGoal == 1000
assert data.dailyGoalAuto is False
assert data._dailyLastDate == date.fromisoformat("2026-07-20")
# The fixture's daily target date (2026-07-20) is stale by the time this
# test runs, so the loaded reference count must be discarded and the
# date bumped to today rather than kept as the stale loaded value
assert data._dailyLastDate == date.today()
assert data._dailyLastCount == 0

assert data.itemStatus["sf12341"].name == "New"
Expand Down
22 changes: 22 additions & 0 deletions tests/editor/test_editor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2719,6 +2719,14 @@ def testGuiDocEditor_InternalSlotEdgeCases(qtbot, nwGUI, projPath, mockRnd):
cursor = docEditor._autoSelect()
assert cursor.selectedText() != ""

# Copying a selection must only put plain text on the clipboard,
# not also HTML, regardless of the widget's rich text rendering
docEditor.setPlainText("first\n\nsecond")
docEditor.docAction(nwDocAction.SEL_ALL)
mime = docEditor.createMimeDataFromSelection()
assert mime.hasHtml() is False
assert mime.text() == "first\n\nsecond"


@pytest.mark.gui
def testGuiDocEditor_UpdateDocMargins(qtbot, nwGUI, projPath, mockRnd):
Expand Down Expand Up @@ -2877,6 +2885,20 @@ def testGuiDocEditor_InsertFromMimeData_Markdown(qtbot, nwGUI, projPath, mockRnd

assert docEditor.getText() == text

# A rich text (HTML) paste with a meaningful leading space (as a
# non-breaking space, which is how rich text sources encode a
# leading space that would otherwise collapse in HTML) must only
# have the blank line added by the HTML-to-text conversion
# stripped, not the leading space itself
docEditor.replaceText("A cat.")
docEditor.setCursorPosition(1)

htmlMime = QMimeData()
htmlMime.setHtml("<p>&nbsp;big</p>")
docEditor.insertFromMimeData(htmlMime)

assert docEditor.getText().startswith("A\u00a0big cat.")


@pytest.mark.gui
def testGuiDocEditor_LineHeightDoubleReturn(qtbot, nwGUI, projPath, mockRnd):
Expand Down
Loading