2017-11-16 08:33:27 +13:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# --!-- coding: utf8 --!--
|
|
|
|
|
|
|
|
from PyQt5.QtCore import QAbstractItemModel, QMimeData
|
|
|
|
from PyQt5.QtCore import QModelIndex
|
|
|
|
from PyQt5.QtCore import QSize
|
|
|
|
from PyQt5.QtCore import QVariant
|
|
|
|
from PyQt5.QtCore import Qt
|
|
|
|
from PyQt5.QtGui import QIcon, QFont
|
|
|
|
from PyQt5.QtWidgets import QTextEdit, qApp
|
|
|
|
from lxml import etree as ET
|
2019-05-10 11:46:21 +12:00
|
|
|
import re
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
from manuskript import enums
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2019-10-15 01:36:06 +13:00
|
|
|
import logging
|
|
|
|
LOGGER = logging.getLogger(__name__)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
class abstractItem():
|
2017-11-18 00:16:39 +13:00
|
|
|
|
2019-09-18 01:07:08 +12:00
|
|
|
# Enum kept on the class for easier access
|
2017-11-18 00:16:39 +13:00
|
|
|
enum = enums.Abstract
|
|
|
|
|
|
|
|
# Used for XML export
|
|
|
|
name = "abstractItem"
|
|
|
|
|
2019-05-10 11:46:21 +12:00
|
|
|
# Regexp from https://stackoverflow.com/questions/8733233/filtering-out-certain-bytes-in-python
|
|
|
|
valid_xml_re = re.compile(u'[^\u0020-\uD7FF\u0009\u000A\u000D\uE000-\uFFFD\U00010000-\U0010FFFF]+')
|
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
def __init__(self, model=None, title="", _type="abstract", xml=None, parent=None, ID=None):
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
self._data = {}
|
|
|
|
self.childItems = []
|
|
|
|
self._parent = None
|
|
|
|
self._model = model
|
2017-11-18 00:16:39 +13:00
|
|
|
|
2017-11-16 08:33:27 +13:00
|
|
|
self.IDs = ["0"] # used by root item to store unique IDs
|
|
|
|
self._lastPath = "" # used by loadSave version_1 to remember which files the items comes from,
|
2017-11-18 00:16:39 +13:00
|
|
|
# in case it is renamed / removed
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
self._data[self.enum.title] = title
|
|
|
|
self._data[self.enum.type] = _type
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2021-02-22 11:45:34 +13:00
|
|
|
if xml != None:
|
2017-11-16 08:33:27 +13:00
|
|
|
self.setFromXML(xml)
|
|
|
|
|
2021-01-05 09:02:14 +13:00
|
|
|
if parent:
|
|
|
|
# add this as a child to the parent, and link to the outlineModel of the parent
|
|
|
|
parent.appendChild(self)
|
|
|
|
|
2017-11-16 08:33:27 +13:00
|
|
|
if ID:
|
2017-11-18 00:16:39 +13:00
|
|
|
self._data[self.enum.ID] = ID
|
2021-01-05 09:02:14 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
#######################################################################
|
|
|
|
# Model
|
|
|
|
#######################################################################
|
|
|
|
|
|
|
|
def setModel(self, model):
|
|
|
|
self._model = model
|
2021-04-10 02:03:59 +12:00
|
|
|
if not self.ID():
|
|
|
|
self.getUniqueID()
|
|
|
|
elif model:
|
|
|
|
# if we are setting a model update it's ID
|
|
|
|
self._model.updateAvailableIDs(self.ID())
|
2017-11-18 00:16:39 +13:00
|
|
|
for c in self.children():
|
|
|
|
c.setModel(model)
|
|
|
|
|
|
|
|
def index(self, column=0):
|
|
|
|
if self._model:
|
|
|
|
return self._model.indexFromItem(self, column)
|
|
|
|
else:
|
|
|
|
return QModelIndex()
|
|
|
|
|
|
|
|
def emitDataChanged(self, cols=None, recursive=False):
|
|
|
|
"""
|
|
|
|
Emits the dataChanged signal of the model, to signal views that data
|
|
|
|
have changed.
|
|
|
|
|
|
|
|
@param cols: an array of int (or None). The columns of the index that
|
|
|
|
have been changed.
|
|
|
|
@param recursive: boolean. If true, all children will also emit the
|
|
|
|
dataChanged signal.
|
|
|
|
"""
|
|
|
|
idx = self.index()
|
|
|
|
if idx and self._model:
|
|
|
|
if not cols:
|
|
|
|
# Emit data changed for the whole item (all columns)
|
|
|
|
self._model.dataChanged.emit(idx, self.index(len(self.enum)))
|
|
|
|
|
|
|
|
else:
|
|
|
|
# Emit only for the specified columns
|
|
|
|
for c in cols:
|
|
|
|
self._model.dataChanged.emit(self.index(c), self.index(c))
|
|
|
|
|
|
|
|
if recursive:
|
|
|
|
for c in self.children():
|
|
|
|
c.emitDataChanged(cols, recursive=True)
|
|
|
|
|
|
|
|
#######################################################################
|
|
|
|
# Properties
|
|
|
|
#######################################################################
|
|
|
|
|
|
|
|
def title(self):
|
|
|
|
return self._data.get(self.enum.title, "")
|
|
|
|
|
|
|
|
def ID(self):
|
2017-11-18 05:38:06 +13:00
|
|
|
return self._data.get(self.enum.ID)
|
2017-11-18 00:16:39 +13:00
|
|
|
|
|
|
|
def columnCount(self):
|
|
|
|
return len(self.enum)
|
|
|
|
|
|
|
|
def type(self):
|
|
|
|
return self._data[self.enum.type]
|
|
|
|
|
|
|
|
#######################################################################
|
2019-09-18 01:07:08 +12:00
|
|
|
# Parent / Children management
|
2017-11-18 00:16:39 +13:00
|
|
|
#######################################################################
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
def child(self, row):
|
|
|
|
return self.childItems[row]
|
|
|
|
|
|
|
|
def childCount(self):
|
|
|
|
return len(self.childItems)
|
|
|
|
|
|
|
|
def childCountRecursive(self):
|
|
|
|
n = self.childCount()
|
|
|
|
for c in self.children():
|
|
|
|
n += c.childCountRecursive()
|
|
|
|
return n
|
|
|
|
|
|
|
|
def children(self):
|
|
|
|
return self.childItems
|
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
def row(self):
|
|
|
|
if self.parent():
|
|
|
|
return self.parent().childItems.index(self)
|
2019-02-24 21:25:41 +13:00
|
|
|
return None
|
2017-11-18 00:16:39 +13:00
|
|
|
|
|
|
|
def appendChild(self, child):
|
|
|
|
self.insertChild(self.childCount(), child)
|
|
|
|
|
|
|
|
def insertChild(self, row, child):
|
|
|
|
self.childItems.insert(row, child)
|
|
|
|
child._parent = self
|
|
|
|
child.setModel(self._model)
|
|
|
|
|
|
|
|
def removeChild(self, row):
|
|
|
|
"""
|
|
|
|
Removes child at position `row` and returns it.
|
|
|
|
@param row: index (int) of the child to remove.
|
|
|
|
@return: the removed abstractItem
|
|
|
|
"""
|
|
|
|
r = self.childItems.pop(row)
|
Fix occasional crashes when (re)moving items
Describing all the rabbitholes that I and kakaroto have gone through
while debugging this one until dawn can frankly not do enough justice to
the crazy amount of rubberducking that went on while trying to fix this.
This bug would be triggered whenever you had a document open in the
editor and then moved an ancestor object downwards (visually) in the tree.
Or when you simply deleted the ancestor. Depending on the exact method
that caused the opened item to be removed from the internal model, the
exact nature of the bug would vary, which means this commit fixes a few
different bits of code that lead to what appears to be the same bug.
In order of appearance, the bugs that ruined our sleep were:
1) The editor widget was trying to handle the removed item at too late a
stage.
2) The editor widget tried to fix its view after a move by searching for
the new item with the same ID, but in the case of moving an object down
it came across its own old item, ruining the attempt.
3) The editor widget did not properly account for the hierarchical
nature of the model.
Upon fixing these the next day, it was revealed that:
4) The outlineItem.updateWordCount(emit=False) flag is broken. This
function would call setData() in several spots which would still cause
emits to bubble through the system despite emit=False, and we simply got
lucky that it stopped enough of them until now.
This last one was caused by a small mistake in the fixes for the first
three bugs, but it has led to a couple of extra changes to make any
future bug hunts slightly less arduous and frustrating:
a) When calling item.removeChild(c), it now resets the associated parent
and model to mirror item.insertChild(c). This has also led to an extra
check in model.parent() to check for its validity.
b) The outlineItem.updateWordCount(emit=) flag has been removed entirely
and it now emits away with reckless abandon. I have been unable to
reproduce the crashes the code warned about, so I consider this a code
quality fix to prevent mysterious future issues where things sometimes
do not properly update right.
Worthy of note is that the original code clearly showed the intention to
close tabs for items that were removed. Reworking the editor to support
closing a tab is unfortunately way out of scope, so this intention was
left in and the new fix was structured to make it trivial to implement
such a change when the time comes. An existing FIXME regarding unrelated
buggy editor behaviour was left in, too.
Many thanks to Kakaroto for burning the midnight oil with me to get to
the bottom of this. (I learned a lot that night!)
Issues #479, #516 and #559 are fixed by this commit. And maybe some others,
too.
2019-05-03 07:45:12 +12:00
|
|
|
# Disassociate the child from its parent and the model.
|
|
|
|
r._parent = None
|
|
|
|
r.setModel(None)
|
2017-11-18 00:16:39 +13:00
|
|
|
return r
|
|
|
|
|
|
|
|
def parent(self):
|
|
|
|
return self._parent
|
|
|
|
|
|
|
|
def path(self, sep=" > "):
|
|
|
|
"Returns path to item as string."
|
|
|
|
if self.parent().parent():
|
|
|
|
return "{parent}{sep}{title}".format(
|
|
|
|
parent=self.parent().path(),
|
|
|
|
sep=sep,
|
|
|
|
title=self.title())
|
|
|
|
else:
|
|
|
|
return self.title()
|
|
|
|
|
|
|
|
def pathID(self):
|
|
|
|
"Returns path to item as list of (ID, title)."
|
|
|
|
if self.parent() and self.parent().parent():
|
|
|
|
return self.parent().pathID() + [(self.ID(), self.title())]
|
|
|
|
else:
|
|
|
|
return [(self.ID(), self.title())]
|
|
|
|
|
|
|
|
def level(self):
|
|
|
|
"""Returns the level of the current item. Root item returns -1."""
|
|
|
|
if self.parent():
|
|
|
|
return self.parent().level() + 1
|
|
|
|
else:
|
|
|
|
return -1
|
|
|
|
|
|
|
|
def copy(self):
|
|
|
|
"""
|
|
|
|
Returns a copy of item, with no parent, and no ID.
|
|
|
|
"""
|
|
|
|
item = self.__class__(xml=self.toXML())
|
|
|
|
item.setData(self.enum.ID, None)
|
|
|
|
return item
|
|
|
|
|
2019-02-24 21:25:41 +13:00
|
|
|
def siblings(self):
|
|
|
|
if self.parent():
|
|
|
|
return self.parent().children()
|
|
|
|
return []
|
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
###############################################################################
|
|
|
|
# IDS
|
|
|
|
###############################################################################
|
|
|
|
|
|
|
|
def getUniqueID(self, recursive=False):
|
2021-01-05 09:02:14 +13:00
|
|
|
self.setData(self.enum.ID, self._model.requestNewID())
|
2017-11-18 00:16:39 +13:00
|
|
|
|
|
|
|
if recursive:
|
|
|
|
for c in self.children():
|
|
|
|
c.getUniqueID(recursive)
|
|
|
|
|
|
|
|
def checkIDs(self):
|
|
|
|
"""This is called when a model is loaded.
|
|
|
|
|
|
|
|
Makes a list of all sub-items IDs, that is used to generate unique IDs afterwards.
|
|
|
|
"""
|
|
|
|
self.IDs = self.listAllIDs()
|
|
|
|
|
|
|
|
if max([self.IDs.count(i) for i in self.IDs if i]) != 1:
|
2019-10-15 01:36:06 +13:00
|
|
|
LOGGER.warning("There are some items with overlapping IDs: %s", [i for i in self.IDs if i and self.IDs.count(i) != 1])
|
2017-11-18 00:16:39 +13:00
|
|
|
|
|
|
|
def checkChildren(item):
|
|
|
|
for c in item.children():
|
|
|
|
_id = c.ID()
|
|
|
|
if not _id or _id == "0":
|
|
|
|
c.getUniqueID()
|
|
|
|
checkChildren(c)
|
|
|
|
|
|
|
|
checkChildren(self)
|
|
|
|
|
|
|
|
def listAllIDs(self):
|
|
|
|
IDs = [self.ID()]
|
|
|
|
for c in self.children():
|
|
|
|
IDs.extend(c.listAllIDs())
|
|
|
|
return IDs
|
|
|
|
|
|
|
|
#######################################################################
|
|
|
|
# Data
|
|
|
|
#######################################################################
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
def data(self, column, role=Qt.DisplayRole):
|
2017-11-18 05:38:06 +13:00
|
|
|
# Return value in self._data
|
2017-11-16 08:33:27 +13:00
|
|
|
if role == Qt.DisplayRole or role == Qt.EditRole:
|
2017-11-18 05:38:06 +13:00
|
|
|
return self._data.get(column, "")
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
# Or return QVariant
|
|
|
|
return QVariant()
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
def setData(self, column, data, role=Qt.DisplayRole):
|
|
|
|
# Setting data
|
2017-11-18 05:38:06 +13:00
|
|
|
self._data[column] = data
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2021-04-10 11:19:03 +12:00
|
|
|
# The _model will be none during splitting
|
|
|
|
if self._model and column == self.enum.ID:
|
2021-01-05 09:02:14 +13:00
|
|
|
self._model.updateAvailableIDs(data)
|
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
# Emit signal
|
|
|
|
self.emitDataChanged(cols=[column]) # new in 0.5.0
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
###############################################################################
|
|
|
|
# XML
|
|
|
|
###############################################################################
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
# We don't want to write some datas (computed)
|
2017-11-18 05:38:06 +13:00
|
|
|
XMLExclude = []
|
2017-11-18 00:16:39 +13:00
|
|
|
# We want to force some data even if they're empty
|
2017-11-18 05:38:06 +13:00
|
|
|
XMLForce = []
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2019-05-10 11:46:21 +12:00
|
|
|
def cleanTextForXML(self, text):
|
|
|
|
return self.valid_xml_re.sub('', text)
|
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
def toXML(self):
|
2017-11-18 05:38:06 +13:00
|
|
|
"""
|
|
|
|
Returns a string containing the item (and children) in XML.
|
|
|
|
By default, saves all attributes from self.enum and lastPath.
|
|
|
|
You can define in XMLExclude and XMLForce what you want to be
|
|
|
|
excluded or forcibly included.
|
|
|
|
"""
|
2017-11-18 00:16:39 +13:00
|
|
|
item = ET.Element(self.name)
|
|
|
|
|
|
|
|
for attrib in self.enum:
|
|
|
|
if attrib in self.XMLExclude:
|
|
|
|
continue
|
|
|
|
val = self.data(attrib)
|
|
|
|
if val or attrib in self.XMLForce:
|
2019-05-10 11:46:21 +12:00
|
|
|
item.set(attrib.name, self.cleanTextForXML(str(val)))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
# Saving lastPath
|
|
|
|
item.set("lastPath", self._lastPath)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
# Additional stuff for subclasses
|
|
|
|
item = self.toXMLProcessItem(item)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
for i in self.childItems:
|
|
|
|
item.append(ET.XML(i.toXML()))
|
|
|
|
|
|
|
|
return ET.tostring(item)
|
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
def toXMLProcessItem(self, item):
|
|
|
|
"""
|
|
|
|
Subclass this to change the behavior of `toXML`.
|
|
|
|
"""
|
|
|
|
return item
|
|
|
|
|
2017-11-16 08:33:27 +13:00
|
|
|
def setFromXML(self, xml):
|
|
|
|
root = ET.XML(xml)
|
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
for k in self.enum:
|
|
|
|
if k.name in root.attrib:
|
|
|
|
self.setData(k, str(root.attrib[k.name]))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
if "lastPath" in root.attrib:
|
|
|
|
self._lastPath = root.attrib["lastPath"]
|
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
self.setFromXMLProcessMore(root)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
for child in root:
|
2017-11-18 05:38:06 +13:00
|
|
|
if child.tag == self.name:
|
|
|
|
item = self.__class__(self._model, xml=ET.tostring(child), parent=self)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 05:38:06 +13:00
|
|
|
def setFromXMLProcessMore(self, root):
|
|
|
|
"""
|
|
|
|
Additional stuff that subclasses must do with the XML to restore
|
|
|
|
item.
|
|
|
|
"""
|
|
|
|
return
|