Skip to content

Commit e901c19

Browse files
authored
Add story notes and comment auto-complete (#2346)
2 parents 7790264 + 5fb45f7 commit e901c19

File tree

15 files changed

+183
-31
lines changed

15 files changed

+183
-31
lines changed

novelwriter/assets/i18n/project_en_GB.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"Footnotes": "Footnotes",
55
"Comment": "Comment",
66
"Story Structure": "Story Structure",
7+
"Note": "Note",
78
"Notes": "Notes",
89
"Tag": "Tag",
910
"Point of View": "Point of View",

novelwriter/core/buildsettings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"text.includeSynopsis": (bool, False),
8080
"text.includeComments": (bool, False),
8181
"text.includeStory": (bool, False),
82+
"text.includeNotes": (bool, False),
8283
"text.includeKeywords": (bool, False),
8384
"text.includeBodyText": (bool, True),
8485
"text.ignoredKeywords": (str, ""),
@@ -146,6 +147,7 @@
146147
"text.includeSynopsis": QT_TRANSLATE_NOOP("Builds", "Include Synopsis"),
147148
"text.includeComments": QT_TRANSLATE_NOOP("Builds", "Include Comments"),
148149
"text.includeStory": QT_TRANSLATE_NOOP("Builds", "Include Story Structure"),
150+
"text.includeNotes": QT_TRANSLATE_NOOP("Builds", "Include Manuscript Notes"),
149151
"text.includeKeywords": QT_TRANSLATE_NOOP("Builds", "Include Keywords"),
150152
"text.includeBodyText": QT_TRANSLATE_NOOP("Builds", "Include Body Text"),
151153
"text.ignoredKeywords": QT_TRANSLATE_NOOP("Builds", "Ignore These Keywords"),

novelwriter/core/docbuild.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,7 @@ def _setupBuild(self, bldObj: Tokenizer) -> dict:
317317
bldObj.setCommentType(nwComment.SYNOPSIS, self._build.getBool("text.includeSynopsis"))
318318
bldObj.setCommentType(nwComment.SHORT, self._build.getBool("text.includeSynopsis"))
319319
bldObj.setCommentType(nwComment.STORY, self._build.getBool("text.includeStory"))
320+
bldObj.setCommentType(nwComment.NOTE, self._build.getBool("text.includeNotes"))
320321

321322
if isinstance(bldObj, ToHtml):
322323
bldObj.setStyles(self._build.getBool("html.addStyles"))

novelwriter/core/index.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -618,6 +618,10 @@ def getStoryKeys(self) -> set[str]:
618618
"""Return all story structure keys."""
619619
return self._itemIndex.allStoryKeys()
620620

621+
def getNoteKeys(self) -> set[str]:
622+
"""Return all note comment keys."""
623+
return self._itemIndex.allNoteKeys()
624+
621625
def novelStructure(
622626
self, rootHandle: str | None = None, activeOnly: bool = True
623627
) -> Iterable[tuple[str, str, str, IndexHeading]]:
@@ -920,11 +924,12 @@ class IndexCache:
920924
which provides lookup capabilities and caching for shared data.
921925
"""
922926

923-
__slots__ = ("story", "tags")
927+
__slots__ = ("note", "story", "tags")
924928

925929
def __init__(self, tagsIndex: TagsIndex) -> None:
926930
self.tags: TagsIndex = tagsIndex
927931
self.story: set[str] = set()
932+
self.note: set[str] = set()
928933
return
929934

930935

@@ -979,6 +984,10 @@ def allStoryKeys(self) -> set[str]:
979984
"""Return all story structure keys."""
980985
return self._cache.story.copy()
981986

987+
def allNoteKeys(self) -> set[str]:
988+
"""Return all note comment keys."""
989+
return self._cache.note.copy()
990+
982991
def allItemTags(self, tHandle: str) -> list[str]:
983992
"""Get all tags set for headings of an item."""
984993
if tHandle in self._items:

novelwriter/core/indexdata.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,9 @@ def setComment(self, comment: str, key: str, text: str) -> None:
316316
case "story" if key:
317317
self._cache.story.add(key)
318318
self._comments[f"story.{key}"] = str(text)
319+
case "note" if key:
320+
self._cache.note.add(key)
321+
self._comments[f"note.{key}"] = str(text)
319322
return
320323

321324
def setTag(self, tag: str) -> None:
@@ -395,7 +398,7 @@ def unpackData(self, data: dict) -> None:
395398
self.addReference(tag, keyword)
396399
else:
397400
raise ValueError("Heading reference contains an invalid keyword")
398-
elif key == "summary" or key.startswith("story"):
401+
elif key == "summary" or key.startswith(("story", "note")):
399402
comment, _, kind = str(key).partition(".")
400403
self.setComment(comment, compact(kind), str(entry))
401404
else:

novelwriter/formats/tokenizer.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,8 @@ def tokenizeText(self) -> None:
614614
tStyle |= BlockFmt.JUSTIFY
615615

616616
if cStyle in (
617-
nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN, nwComment.STORY
617+
nwComment.SYNOPSIS, nwComment.SHORT, nwComment.PLAIN,
618+
nwComment.STORY, nwComment.NOTE,
618619
):
619620
bStyle = COMMENT_STYLE[cStyle]
620621
tLine, tFmt = self._formatComment(bStyle, cKey, cText)

novelwriter/gui/doceditor.py

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@
33
=================================
44
55
File History:
6-
Created: 2018-09-29 [0.0.1] GuiDocEditor
7-
Created: 2019-04-22 [0.0.1] BackgroundWordCounter
8-
Created: 2019-09-29 [0.2.1] GuiDocEditSearch
9-
Created: 2020-04-25 [0.4.5] GuiDocEditHeader
10-
Rewritten: 2020-06-15 [0.9] GuiDocEditSearch
11-
Created: 2020-06-27 [0.10] GuiDocEditFooter
12-
Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter
13-
Created: 2023-11-06 [2.2b1] MetaCompleter
14-
Created: 2023-11-07 [2.2b1] GuiDocToolBar
6+
Created: 2018-09-29 [0.0.1] GuiDocEditor
7+
Created: 2019-04-22 [0.0.1] BackgroundWordCounter
8+
Created: 2019-09-29 [0.2.1] GuiDocEditSearch
9+
Created: 2020-04-25 [0.4.5] GuiDocEditHeader
10+
Rewritten: 2020-06-15 [0.9] GuiDocEditSearch
11+
Created: 2020-06-27 [0.10] GuiDocEditFooter
12+
Rewritten: 2020-10-07 [1.0b3] BackgroundWordCounter
13+
Created: 2023-11-06 [2.2b1] MetaCompleter
14+
Created: 2023-11-07 [2.2b1] GuiDocToolBar
15+
Extended: 2025-05-18 [2.7rc1] CommandCompleter
1516
1617
This file is a part of novelWriter
1718
Copyright (C) 2018 Veronica Berglyd Olsen and novelWriter contributors
@@ -149,7 +150,7 @@ def __init__(self, parent: QWidget) -> None:
149150
self._autoReplace = TextAutoReplace()
150151

151152
# Completer
152-
self._completer = MetaCompleter(self)
153+
self._completer = CommandCompleter(self)
153154
self._completer.complete.connect(self._insertCompletion)
154155

155156
# Create Custom Document
@@ -1079,13 +1080,16 @@ def _docChange(self, pos: int, removed: int, added: int) -> None:
10791080

10801081
if (block := self._qDocument.findBlock(pos)).isValid():
10811082
text = block.text()
1082-
if text.startswith("@") and added + removed == 1:
1083+
if text and text[0] in "@%" and added + removed == 1:
10831084
# Only run on single character changes, or it will trigger
10841085
# at unwanted times when other changes are made to the document
10851086
cursor = self.textCursor()
10861087
bPos = cursor.positionInBlock()
10871088
if bPos > 0 and (viewport := self.viewport()):
1088-
show = self._completer.updateText(text, bPos)
1089+
if text[0] == "@":
1090+
show = self._completer.updateMetaText(text, bPos)
1091+
else:
1092+
show = self._completer.updateCommentText(text, bPos)
10891093
point = self.cursorRect().bottomRight()
10901094
self._completer.move(viewport.mapToGlobal(point))
10911095
self._completer.setVisible(show)
@@ -2073,13 +2077,13 @@ def _allowAutoReplace(self, state: bool) -> None:
20732077
return
20742078

20752079

2076-
class MetaCompleter(QMenu):
2077-
"""GuiWidget: Meta Completer Menu
2080+
class CommandCompleter(QMenu):
2081+
"""GuiWidget: Command Completer Menu
20782082
20792083
This is a context menu with options populated from the user's
2080-
defined tags. It also helps to type the meta data keyword on a new
2081-
line starting with an @. The updateText function should be called on
2082-
every keystroke on a line starting with @.
2084+
defined tags and keys. It also helps to type the meta data keyword
2085+
on a new line starting with @ or %. The update functions should be
2086+
called on every keystroke on a line starting with @ or %.
20832087
"""
20842088

20852089
complete = pyqtSignal(int, int, str)
@@ -2088,15 +2092,15 @@ def __init__(self, parent: QWidget) -> None:
20882092
super().__init__(parent=parent)
20892093
return
20902094

2091-
def updateText(self, text: str, pos: int) -> bool:
2095+
def updateMetaText(self, text: str, pos: int) -> bool:
20922096
"""Update the menu options based on the line of text."""
20932097
self.clear()
20942098
kw, sep, _ = text.partition(":")
20952099
if pos <= len(kw):
20962100
offset = 0
20972101
length = len(kw.rstrip())
20982102
suffix = "" if sep else ":"
2099-
options = list(filter(
2103+
options = sorted(filter(
21002104
lambda x: x.startswith(kw.rstrip()), nwKeyWords.VALID_KEYS
21012105
))
21022106
else:
@@ -2108,7 +2112,7 @@ def updateText(self, text: str, pos: int) -> bool:
21082112
offset = tPos[index] if lookup else pos
21092113
length = len(lookup)
21102114
suffix = ""
2111-
options = list(filter(
2115+
options = sorted(filter(
21122116
lambda x: lookup in x.lower(), SHARED.project.index.getClassTags(
21132117
nwKeyWords.KEY_CLASS.get(kw.strip())
21142118
)
@@ -2117,13 +2121,57 @@ def updateText(self, text: str, pos: int) -> bool:
21172121
if not options:
21182122
return False
21192123

2120-
for value in sorted(options):
2124+
for value in options:
21212125
rep = value + suffix
21222126
action = qtAddAction(self, value)
21232127
action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep))
21242128

21252129
return True
21262130

2131+
def updateCommentText(self, text: str, pos: int) -> bool:
2132+
"""Update the menu options based on the line of text."""
2133+
self.clear()
2134+
cmd, sep, _ = text.partition(":")
2135+
if pos <= len(cmd):
2136+
clean = text[1:].lstrip()[:6].lower()
2137+
if clean[:6] == "story.":
2138+
pre, _, key = cmd.partition(".")
2139+
offset = len(pre) + 1
2140+
length = len(key)
2141+
suffix = "" if sep else ": "
2142+
options = sorted(filter(
2143+
lambda x: x.startswith(key.rstrip()),
2144+
SHARED.project.index.getStoryKeys(),
2145+
))
2146+
elif clean[:5] == "note.":
2147+
pre, _, key = cmd.partition(".")
2148+
offset = len(pre) + 1
2149+
length = len(key)
2150+
suffix = "" if sep else ": "
2151+
options = sorted(filter(
2152+
lambda x: x.startswith(key.rstrip()),
2153+
SHARED.project.index.getNoteKeys(),
2154+
))
2155+
elif pos < 12:
2156+
offset = 0
2157+
length = len(cmd.rstrip())
2158+
suffix = ""
2159+
options = list(filter(
2160+
lambda x: x.startswith(cmd.rstrip()),
2161+
["%Synopsis: ", "%Short: ", "%Story", "%Note"],
2162+
))
2163+
else:
2164+
return False
2165+
2166+
if options:
2167+
for value in options:
2168+
rep = value + suffix
2169+
action = qtAddAction(self, rep.rstrip(":. "))
2170+
action.triggered.connect(qtLambda(self._emitComplete, offset, length, rep))
2171+
return True
2172+
2173+
return False
2174+
21272175
##
21282176
# Events
21292177
##

novelwriter/gui/outline.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -751,9 +751,13 @@ def _populateTree(self, rootHandle: str | None) -> None:
751751
def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
752752
"""Dump all novel data into a table."""
753753
sLabel = SHARED.project.localLookup("Story Structure")
754+
nLabel = SHARED.project.localLookup("Note")
754755
sKeys = sorted(SHARED.project.index.getStoryKeys())
756+
nKeys = sorted(SHARED.project.index.getNoteKeys())
755757
sMatch = [f"story.{k}" for k in sKeys]
758+
nMatch = [f"note.{k}" for k in nKeys]
756759
sHeaders = [f"{sLabel} ({k})" for k in sKeys]
760+
nHeaders = [f"{nLabel} ({k})" for k in nKeys]
757761

758762
data: list[list[str | int]] = [[
759763
"H",
@@ -777,6 +781,7 @@ def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
777781
trConst(nwLabels.OUTLINE_COLS[nwOutline.MENTION]),
778782
trConst(nwLabels.OUTLINE_COLS[nwOutline.SYNOP]),
779783
*sHeaders,
784+
*nHeaders,
780785
]]
781786

782787
for _, tHandle, sTitle, novIdx in SHARED.project.index.novelStructure(
@@ -786,6 +791,7 @@ def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
786791
refs = SHARED.project.index.getReferences(tHandle, sTitle)
787792
comments = dict(novIdx.comments.items())
788793
story = [comments.get(k, "") for k in sMatch]
794+
notes = [comments.get(k, "") for k in nMatch]
789795
data.append([
790796
novIdx.level,
791797
novIdx.title,
@@ -808,6 +814,7 @@ def _dumpNovelData(self, rootHandle: str | None) -> list[list[str | int]]:
808814
", ".join(refs[nwKeyWords.MENTION_KEY]),
809815
novIdx.synopsis,
810816
*story,
817+
*notes,
811818
])
812819

813820
return data

novelwriter/tools/manuscript.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,7 @@ def updateInfo(self, build: BuildSettings) -> None:
630630
self.listView.addTopLevelItem(item)
631631
for key in [
632632
"text.includeSynopsis", "text.includeComments", "text.includeStory",
633-
"text.includeKeywords", "text.includeBodyText",
633+
"text.includeNotes", "text.includeKeywords", "text.includeBodyText",
634634
]:
635635
sub = QTreeWidgetItem()
636636
sub.setText(0, build.getLabel(key))

novelwriter/tools/manussettings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -974,12 +974,14 @@ def buildForm(self) -> None:
974974
self.incSynopsis = NSwitch(self, height=iPx)
975975
self.incComments = NSwitch(self, height=iPx)
976976
self.incStory = NSwitch(self, height=iPx)
977+
self.incNotes = NSwitch(self, height=iPx)
977978
self.incKeywords = NSwitch(self, height=iPx)
978979

979980
self.addRow(self._build.getLabel("text.includeBodyText"), self.incBodyText)
980981
self.addRow(self._build.getLabel("text.includeSynopsis"), self.incSynopsis)
981982
self.addRow(self._build.getLabel("text.includeComments"), self.incComments)
982983
self.addRow(self._build.getLabel("text.includeStory"), self.incStory)
984+
self.addRow(self._build.getLabel("text.includeNotes"), self.incNotes)
983985
self.addRow(self._build.getLabel("text.includeKeywords"), self.incKeywords)
984986

985987
# Ignored Keywords
@@ -1288,6 +1290,7 @@ def loadContent(self) -> None:
12881290
self.incSynopsis.setChecked(self._build.getBool("text.includeSynopsis"))
12891291
self.incComments.setChecked(self._build.getBool("text.includeComments"))
12901292
self.incStory.setChecked(self._build.getBool("text.includeStory"))
1293+
self.incNotes.setChecked(self._build.getBool("text.includeNotes"))
12911294
self.incKeywords.setChecked(self._build.getBool("text.includeKeywords"))
12921295
self.ignoredKeywords.setText(self._build.getStr("text.ignoredKeywords"))
12931296
self.addNoteHead.setChecked(self._build.getBool("text.addNoteHeadings"))
@@ -1387,6 +1390,7 @@ def saveContent(self) -> None:
13871390
self._build.setValue("text.includeSynopsis", self.incSynopsis.isChecked())
13881391
self._build.setValue("text.includeComments", self.incComments.isChecked())
13891392
self._build.setValue("text.includeStory", self.incStory.isChecked())
1393+
self._build.setValue("text.includeNotes", self.incNotes.isChecked())
13901394
self._build.setValue("text.includeKeywords", self.incKeywords.isChecked())
13911395
self._build.setValue("text.ignoredKeywords", self.ignoredKeywords.text())
13921396
self._build.setValue("text.addNoteHeadings", self.addNoteHead.isChecked())

0 commit comments

Comments
 (0)