588 lines
19 KiB
Python
588 lines
19 KiB
Python
"""
|
||
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
|