""" 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 . """ # 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"" 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