Files
novelWriter/novelwriter/core/item.py
T
Veronica Berglyd Olsen c174f8f931 Update linting for main code
2025-08-27 21:00:09 +02:00

588 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
novelWriter Project Item Class
================================
File History:
Created: 2018-10-27 [0.0.1] NWItem
This file is a part of novelWriter
Copyright (C) 2018 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 <https://www.gnu.org/licenses/>.
""" # noqa
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from novelwriter import CONFIG, SHARED
from novelwriter.common import (
checkInt, isHandle, isItemClass, isItemLayout, isItemType, simplified,
yesNo
)
from novelwriter.constants import nwLabels, nwStyles, trConst
from novelwriter.enum import nwItemClass, nwItemLayout, nwItemType
if TYPE_CHECKING:
from PyQt6.QtGui import QFont, QIcon
from novelwriter.core.project import NWProject
logger = logging.getLogger(__name__)
class NWItem:
"""Core: Item Data Class.
This class holds all the project information about a project item.
Each item must be associated with a project and have a valid handle.
Only the NWTree class should create instances of this class, and
must ensure that the handle is valid for all items in the tree.
"""
__slots__ = (
"_active", "_charCount", "_charInit", "_class", "_cursorPos",
"_expanded", "_handle", "_heading", "_import", "_layout", "_name",
"_order", "_paraCount", "_parent", "_project", "_root", "_status",
"_type", "_wordCount", "_wordInit",
)
def __init__(self, project: NWProject, handle: str) -> None:
self._project = project
self._name = ""
self._handle = handle
self._parent = None
self._root = None
self._order = 0
self._type = nwItemType.NO_TYPE
self._class = nwItemClass.NO_CLASS
self._layout = nwItemLayout.NO_LAYOUT
self._status = None
self._import = None
self._active = True
self._expanded = False
# Document Meta Data
self._heading = "H0" # The main heading
self._charCount = 0 # Current character count
self._wordCount = 0 # Current word count
self._paraCount = 0 # Current paragraph count
self._cursorPos = 0 # Last cursor position
self._wordInit = 0 # Initial character count
self._charInit = 0 # Initial word count
def __repr__(self) -> str:
return f"<NWItem handle={self._handle}, parent={self._parent}, name='{self._name}'>"
def __bool__(self) -> bool:
"""Check the truthiness of the class. The handle used to be
initiated to None, but this is no longer the case. It should
always evaluate to True since 2.1-beta1, although unpack and the
NWTree class can leave it as an empty string.
"""
return bool(self._handle)
##
# Properties
##
@property
def itemName(self) -> str:
return self._name
@property
def itemHandle(self) -> str:
return self._handle
@property
def itemParent(self) -> str | None:
return self._parent
@property
def itemRoot(self) -> str | None:
return self._root
@property
def itemOrder(self) -> int:
return self._order
@property
def itemType(self) -> nwItemType:
return self._type
@property
def itemClass(self) -> nwItemClass:
return self._class
@property
def itemLayout(self) -> nwItemLayout:
return self._layout
@property
def itemStatus(self) -> str | None:
return self._status
@property
def itemImport(self) -> str | None:
return self._import
@property
def isActive(self) -> bool:
return self._active
@property
def isExpanded(self) -> bool:
return self._expanded
@property
def mainHeading(self) -> str:
return self._heading
@property
def charCount(self) -> int:
return self._charCount
@property
def wordCount(self) -> int:
return self._wordCount
@property
def paraCount(self) -> int:
return self._paraCount
@property
def mainCount(self) -> int:
return self._charCount if CONFIG.useCharCount else self._wordCount
@property
def initCount(self) -> int:
return self._wordInit if CONFIG.useCharCount else self._charInit
@property
def cursorPos(self) -> int:
return self._cursorPos
##
# Pack/Unpack/Duplicate Data
##
def pack(self) -> dict:
"""Pack all the data in the class instance into a dictionary."""
item: dict[str, str] = {}
meta: dict[str, str] = {}
name: dict[str, str] = {}
item["handle"] = str(self._handle)
item["parent"] = str(self._parent)
item["root"] = str(self._root)
item["order"] = str(self._order)
item["type"] = str(self._type.name)
item["class"] = str(self._class.name)
meta["expanded"] = yesNo(self._expanded)
name["status"] = str(self._status)
name["import"] = str(self._import)
if self._type == nwItemType.FILE:
item["layout"] = str(self._layout.name)
meta["heading"] = str(self._heading)
meta["charCount"] = str(self._charCount)
meta["wordCount"] = str(self._wordCount)
meta["paraCount"] = str(self._paraCount)
meta["cursorPos"] = str(self._cursorPos)
name["active"] = yesNo(self._active)
return {
"name": str(self._name),
"itemAttr": item,
"metaAttr": meta,
"nameAttr": name,
}
def unpack(self, data: dict) -> bool:
"""Set the values from a data dictionary."""
item = data.get("itemAttr", {})
meta = data.get("metaAttr", {})
name = data.get("nameAttr", {})
handle = item.get("handle", "")
if isHandle(handle):
self._handle = handle
else:
logger.error("Item does not have a handle")
return False
self.setName(data.get("name", ""))
self.setParent(item.get("parent", None))
self.setRoot(item.get("root", None))
self.setOrder(item.get("order", 0))
self.setType(item.get("type", nwItemType.NO_TYPE))
self.setClass(item.get("class", nwItemClass.NO_CLASS))
self.setExpanded(meta.get("expanded", False))
self.setStatus(name.get("status", None))
self.setImport(name.get("import", None))
if self._type == nwItemType.FILE:
self.setLayout(item.get("layout", nwItemLayout.NO_LAYOUT))
self.setMainHeading(meta.get("heading", "H0"))
self.setCharCount(meta.get("charCount", 0))
self.setWordCount(meta.get("wordCount", 0))
self.setParaCount(meta.get("paraCount", 0))
self.setCursorPos(meta.get("cursorPos", 0))
self.setActive(name.get("active", True))
# Make some checks to ensure consistency
if self._type == nwItemType.ROOT:
self._root = self._handle # Root items are their own ancestor
self._parent = None # Root items cannot have a parent
if self._type != nwItemType.FILE:
# Reset values that should only be set for files
self._layout = nwItemLayout.NO_LAYOUT
self._heading = "H0"
self._active = False
self._charCount = 0
self._wordCount = 0
self._paraCount = 0
self._cursorPos = 0
self._wordInit = self._charCount
self._charInit = self._wordCount
return True
@classmethod
def duplicate(cls, source: NWItem, handle: str) -> NWItem:
"""Make a copy of an item."""
new = cls(source._project, handle)
new._name = source._name
new._parent = source._parent
new._root = source._root
new._order = source._order
new._type = source._type
new._class = source._class
new._layout = source._layout
new._status = source._status
new._import = source._import
new._active = source._active
new._expanded = source._expanded
new._heading = source._heading
new._charCount = source._charCount
new._wordCount = source._wordCount
new._paraCount = source._paraCount
new._cursorPos = source._cursorPos
new._wordInit = source._wordInit
new._charInit = source._charInit
return new
##
# Action Methods
##
def notifyToRefresh(self) -> None:
"""Notify GUI that item info needs to be refreshed."""
self._project.tree.refreshItems([self._handle])
def notifyNovelStructureChange(self) -> None:
"""Notify that the structure of a novel has changed."""
if self._root and self._class == nwItemClass.NOVEL:
self._project.tree.novelStructureChanged(self._root)
##
# Lookup Methods
##
def describeMe(self) -> str:
"""Return a string description of the item."""
descKey = "none"
if self._type == nwItemType.ROOT:
descKey = "root"
elif self._type == nwItemType.FOLDER:
descKey = "folder"
elif self._type == nwItemType.FILE:
if self._layout == nwItemLayout.DOCUMENT:
if self._heading == "H1":
descKey = "doc_h1"
elif self._heading == "H2":
descKey = "doc_h2"
elif self._heading == "H3":
descKey = "doc_h3"
elif self._heading == "H4":
descKey = "doc_h4"
else:
descKey = "document"
elif self._layout == nwItemLayout.NOTE:
descKey = "note"
return trConst(nwLabels.ITEM_DESCRIPTION.get(descKey, ""))
def getMainIcon(self) -> QIcon:
"""Get the main item icon."""
return SHARED.theme.getItemIcon(self._type, self._class, self._layout, self._heading)
def getMainFont(self) -> QFont:
"""Get the main item icon."""
if CONFIG.emphLabels and self._layout == nwItemLayout.DOCUMENT:
if self._heading == "H1":
return SHARED.theme.guiFontBU
elif self._heading == "H2":
return SHARED.theme.guiFontB
return SHARED.theme.guiFont
def getImportStatus(self) -> tuple[str, QIcon]:
"""Return the relevant importance or status label and icon for
the current item based on its class.
"""
if self.isNovelLike():
entry = self._project.data.itemStatus[self._status]
else:
entry = self._project.data.itemImport[self._import]
return entry.name, entry.icon
def getActiveStatus(self) -> tuple[str, QIcon]:
"""Return the relevant active status label and icon for
the current item based on its type.
"""
if self.isFileType():
key = "checked" if self._active else "unchecked"
color = "active" if self._active else "inactive"
text = trConst(nwLabels.ACTIVE_NAME[key])
else:
key = "noncheckable"
color = "disabled"
text = ""
return text, SHARED.theme.getIcon(key, color)
##
# Checker Methods
##
def isNovelLike(self) -> bool:
"""Check if the item is of a novel-like class."""
return self._class in (
nwItemClass.NOVEL,
nwItemClass.ARCHIVE,
nwItemClass.TEMPLATE,
)
def isTemplateFile(self) -> bool:
"""Check if the item is a template file."""
return self._type == nwItemType.FILE and self._class == nwItemClass.TEMPLATE
def documentAllowed(self) -> bool:
"""Check if the item is allowed to be of document layout."""
return self._class in (
nwItemClass.NOVEL,
nwItemClass.ARCHIVE,
nwItemClass.TEMPLATE,
nwItemClass.TRASH,
)
def isInactiveClass(self) -> bool:
"""Check if the item is in an inactive class."""
return self._class in (
nwItemClass.NO_CLASS,
nwItemClass.ARCHIVE,
nwItemClass.TEMPLATE,
nwItemClass.TRASH,
)
def isRootType(self) -> bool:
"""Check if item is a root item."""
return self._type == nwItemType.ROOT
def isFolderType(self) -> bool:
"""Check if item is a folder item."""
return self._type == nwItemType.FOLDER
def isFileType(self) -> bool:
"""Check if item is a file item."""
return self._type == nwItemType.FILE
def isNoteLayout(self) -> bool:
"""Check if item is a project note."""
return self._layout == nwItemLayout.NOTE
def isDocumentLayout(self) -> bool:
"""Check if item is a novel document."""
return self._layout == nwItemLayout.DOCUMENT
##
# Special Setters
##
def setClassDefaults(self, itemClass: nwItemClass) -> None:
"""Set the default values based on the item's class and the
project settings.
"""
if self._parent is not None:
# Only update for child items
if itemClass != self._class:
self.setClass(itemClass)
if self._type == nwItemType.FILE:
# Notify the index of the class change
self._project.index.refreshHandle(self._handle)
if self._layout == nwItemLayout.NO_LAYOUT:
# If no layout is set, pick one
if self.isNovelLike():
self._layout = nwItemLayout.DOCUMENT
else:
self._layout = nwItemLayout.NOTE
elif not self.documentAllowed():
# Change layout to note if it is not in an allowed folder
self._layout = nwItemLayout.NOTE
if self._status is None:
self.setStatus("New") # This forces a default value lookup
if self._import is None:
self.setImport("New") # This forces a default value lookup
##
# Set Item Values
##
def setName(self, name: Any) -> None:
"""Set the item name."""
if isinstance(name, str):
self._name = simplified(name)
else:
self._name = ""
def setParent(self, handle: Any) -> None:
"""Set the parent handle, and ensure it is valid."""
if handle is None:
self._parent = None
elif isHandle(handle):
self._parent = handle
else:
self._parent = None
def setRoot(self, handle: Any) -> None:
"""Set the root handle, and ensure it is valid."""
if handle is None:
self._root = None
elif isHandle(handle):
self._root = handle
else:
self._root = None
def setOrder(self, order: Any) -> None:
"""Set the item order, and ensure that it is valid. This value
is purely a meta value, and not actually used by novelWriter at
the moment.
"""
self._order = checkInt(order, 0)
def setType(self, value: Any) -> None:
"""Set the item type from either a proper nwItemType, or set it
from a string representing an nwItemType.
"""
if isinstance(value, nwItemType):
self._type = value
elif isItemType(value):
self._type = nwItemType[value]
else:
logger.error("Unrecognised item type '%s'", value)
self._type = nwItemType.NO_TYPE
def setClass(self, value: Any) -> None:
"""Set the item class from either a proper nwItemClass, or set
it from a string representing an nwItemClass.
"""
if isinstance(value, nwItemClass):
self._class = value
elif isItemClass(value):
self._class = nwItemClass[value]
else:
logger.error("Unrecognised item class '%s'", value)
self._class = nwItemClass.NO_CLASS
def setLayout(self, value: Any) -> None:
"""Set the item layout from either a proper nwItemLayout, or set
it from a string representing an nwItemLayout.
"""
if isinstance(value, nwItemLayout):
self._layout = value
elif isItemLayout(value):
self._layout = nwItemLayout[value]
else:
logger.error("Unrecognised item layout '%s'", value)
self._layout = nwItemLayout.NO_LAYOUT
def setStatus(self, value: Any) -> None:
"""Set the item status by looking it up in the valid status
items of the current project.
"""
self._status = self._project.data.itemStatus.check(value)
def setImport(self, value: Any) -> None:
"""Set the item importance by looking it up in the valid import
items of the current project.
"""
self._import = self._project.data.itemImport.check(value)
def setActive(self, state: Any) -> None:
"""Set the active flag."""
if isinstance(state, bool):
self._active = state
else:
self._active = False
def setExpanded(self, state: Any) -> None:
"""Set the expanded status of an item in the project tree."""
if isinstance(state, bool):
self._expanded = state
else:
self._expanded = False
##
# Set Document Meta Data
##
def setMainHeading(self, value: str) -> None:
"""Set the main heading level."""
if value in nwStyles.H_LEVEL:
self._heading = value
def setCharCount(self, count: Any) -> None:
"""Set the character count, and ensure that it is an integer."""
if isinstance(count, int):
self._charCount = max(0, count)
else:
self._charCount = 0
def setWordCount(self, count: Any) -> None:
"""Set the word count, and ensure that it is an integer."""
if isinstance(count, int):
self._wordCount = max(0, count)
else:
self._wordCount = 0
def setParaCount(self, count: Any) -> None:
"""Set the paragraph count, and ensure that it is an integer."""
if isinstance(count, int):
self._paraCount = max(0, count)
else:
self._paraCount = 0
def setCursorPos(self, position: Any) -> None:
"""Set the cursor position, and ensure that it is an integer."""
if isinstance(position, int):
self._cursorPos = max(0, position)
else:
self._cursorPos = 0