2017-11-16 08:33:27 +13:00
|
|
|
#!/usr/bin/env python
|
|
|
|
# --!-- coding: utf8 --!--
|
|
|
|
|
|
|
|
import locale
|
|
|
|
|
|
|
|
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 manuskript import settings
|
|
|
|
from lxml import etree as ET
|
|
|
|
|
|
|
|
from manuskript.enums import Outline
|
2017-11-18 00:16:39 +13:00
|
|
|
from manuskript import enums
|
2017-11-16 08:33:27 +13:00
|
|
|
from manuskript.functions import mainWindow, toInt, wordCount
|
|
|
|
from manuskript.converters import HTML2PlainText
|
|
|
|
|
|
|
|
try:
|
|
|
|
locale.setlocale(locale.LC_ALL, '')
|
|
|
|
except:
|
|
|
|
# Invalid locale, but not really a big deal because it's used only for
|
|
|
|
# number formating
|
|
|
|
pass
|
2017-11-18 00:16:39 +13:00
|
|
|
import os
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
|
|
|
|
class abstractItem():
|
2017-11-18 00:16:39 +13:00
|
|
|
|
|
|
|
# Enum kept on the class for easier acces
|
|
|
|
enum = enums.Abstract
|
|
|
|
|
|
|
|
# Used for XML export
|
|
|
|
name = "abstractItem"
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
if xml is not None:
|
|
|
|
self.setFromXML(xml)
|
|
|
|
|
|
|
|
if parent:
|
|
|
|
parent.appendChild(self)
|
|
|
|
|
|
|
|
if ID:
|
2017-11-18 00:16:39 +13:00
|
|
|
self._data[self.enum.ID] = ID
|
|
|
|
|
|
|
|
#######################################################################
|
|
|
|
# Model
|
|
|
|
#######################################################################
|
|
|
|
|
|
|
|
def setModel(self, model):
|
|
|
|
self._model = model
|
|
|
|
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):
|
|
|
|
return self._data.get(self.enum.ID, 0)
|
|
|
|
|
|
|
|
def columnCount(self):
|
|
|
|
return len(self.enum)
|
|
|
|
|
|
|
|
def type(self):
|
|
|
|
return self._data[self.enum.type]
|
|
|
|
|
|
|
|
#######################################################################
|
|
|
|
# Parent / Children managment
|
|
|
|
#######################################################################
|
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)
|
|
|
|
|
|
|
|
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)
|
|
|
|
if not child.ID():
|
|
|
|
child.getUniqueID()
|
|
|
|
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
|
|
|
|
###############################################################################
|
|
|
|
# IDS
|
|
|
|
###############################################################################
|
|
|
|
|
|
|
|
def getUniqueID(self, recursive=False):
|
|
|
|
self.setData(Outline.ID, self._model.rootItem.findUniqueID())
|
|
|
|
|
|
|
|
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:
|
|
|
|
print("WARNING ! There are some items with same IDs:", [i for i in self.IDs if i and self.IDs.count(i) != 1])
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
def findUniqueID(self):
|
|
|
|
IDs = [int(i) for i in self.IDs]
|
|
|
|
k = 1
|
|
|
|
while k in IDs:
|
|
|
|
k += 1
|
|
|
|
self.IDs.append(str(k))
|
|
|
|
return str(k)
|
|
|
|
|
|
|
|
#######################################################################
|
|
|
|
# Data
|
|
|
|
#######################################################################
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
def data(self, column, role=Qt.DisplayRole):
|
|
|
|
|
|
|
|
# print("Data: ", column, role)
|
|
|
|
|
|
|
|
if role == Qt.DisplayRole or role == Qt.EditRole:
|
2017-11-16 08:58:12 +13:00
|
|
|
# if column == Outline.compile:
|
2017-11-16 08:33:27 +13:00
|
|
|
# return self.data(column, Qt.CheckStateRole)
|
|
|
|
|
|
|
|
if Outline(column) in self._data:
|
|
|
|
return self._data[Outline(column)]
|
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
elif column == Outline.revisions:
|
2017-11-16 08:33:27 +13:00
|
|
|
return []
|
|
|
|
|
|
|
|
else:
|
|
|
|
return ""
|
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
elif role == Qt.DecorationRole and column == Outline.title:
|
2017-11-16 08:33:27 +13:00
|
|
|
if self.customIcon():
|
2017-11-16 08:58:12 +13:00
|
|
|
return QIcon.fromTheme(self.data(Outline.customIcon))
|
2017-11-16 08:33:27 +13:00
|
|
|
if self.isFolder():
|
|
|
|
return QIcon.fromTheme("folder")
|
|
|
|
elif self.isMD():
|
|
|
|
return QIcon.fromTheme("text-x-generic")
|
|
|
|
|
|
|
|
# elif role == Qt.ForegroundRole:
|
|
|
|
# if self.isCompile() in [0, "0"]:
|
|
|
|
# return QBrush(Qt.gray)
|
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
elif role == Qt.CheckStateRole and column == Outline.compile:
|
2017-11-16 08:33:27 +13:00
|
|
|
# print(self.title(), self.compile())
|
|
|
|
# if self._data[Outline(column)] and not self.compile():
|
|
|
|
# return Qt.PartiallyChecked
|
|
|
|
# else:
|
|
|
|
return self._data[Outline(column)]
|
|
|
|
|
|
|
|
elif role == Qt.FontRole:
|
|
|
|
f = QFont()
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.wordCount and self.isFolder():
|
2017-11-16 08:33:27 +13:00
|
|
|
f.setItalic(True)
|
2017-11-16 08:58:12 +13:00
|
|
|
elif column == Outline.goal and self.isFolder() and self.data(Outline.setGoal) == None:
|
2017-11-16 08:33:27 +13:00
|
|
|
f.setItalic(True)
|
|
|
|
if self.isFolder():
|
|
|
|
f.setBold(True)
|
|
|
|
return f
|
|
|
|
|
|
|
|
def setData(self, column, data, role=Qt.DisplayRole):
|
|
|
|
if role not in [Qt.DisplayRole, Qt.EditRole, Qt.CheckStateRole]:
|
2017-11-16 08:58:12 +13:00
|
|
|
print(column, column == Outline.text, data, role)
|
2017-11-16 08:33:27 +13:00
|
|
|
return
|
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.text and self.isFolder():
|
2017-11-16 08:33:27 +13:00
|
|
|
# Folder have no text
|
|
|
|
return
|
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.goal:
|
2017-11-16 08:33:27 +13:00
|
|
|
self._data[Outline.setGoal] = toInt(data) if toInt(data) > 0 else ""
|
|
|
|
|
|
|
|
# Checking if we will have to recount words
|
|
|
|
updateWordCount = False
|
2017-11-16 09:05:48 +13:00
|
|
|
if column in [Outline.wordCount, Outline.goal, Outline.setGoal]:
|
2017-11-16 08:33:27 +13:00
|
|
|
updateWordCount = not Outline(column) in self._data or self._data[Outline(column)] != data
|
|
|
|
|
|
|
|
# Stuff to do before
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.text:
|
2017-11-16 08:33:27 +13:00
|
|
|
self.addRevision()
|
|
|
|
|
|
|
|
# Setting data
|
|
|
|
self._data[Outline(column)] = data
|
|
|
|
|
|
|
|
# Stuff to do afterwards
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.text:
|
2017-11-16 08:33:27 +13:00
|
|
|
wc = wordCount(data)
|
2017-11-16 08:58:12 +13:00
|
|
|
self.setData(Outline.wordCount, wc)
|
|
|
|
self.emitDataChanged(cols=[Outline.text]) # new in 0.5.0
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.compile:
|
2017-11-16 09:05:48 +13:00
|
|
|
self.emitDataChanged(cols=[Outline.title, Outline.compile], recursive=True)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-16 08:58:12 +13:00
|
|
|
if column == Outline.customIcon:
|
2017-11-16 08:33:27 +13:00
|
|
|
# If custom icon changed, we tell views to update title (so that icons
|
|
|
|
# will be updated as well)
|
2017-11-16 08:58:12 +13:00
|
|
|
self.emitDataChanged(cols=[Outline.title])
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
if updateWordCount:
|
|
|
|
self.updateWordCount()
|
|
|
|
|
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)
|
|
|
|
XMLExclude = [Outline.wordCount, Outline.goal, Outline.goalPercentage, Outline.revisions]
|
|
|
|
# We want to force some data even if they're empty
|
|
|
|
XMLForce = [Outline.compile]
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
def toXML(self):
|
|
|
|
item = ET.Element(self.name)
|
|
|
|
|
|
|
|
## We don't want to write some datas (computed)
|
|
|
|
#exclude = [Outline.wordCount, Outline.goal, Outline.goalPercentage, Outline.revisions]
|
|
|
|
## We want to force some data even if they're empty
|
|
|
|
#force = [Outline.compile]
|
|
|
|
|
|
|
|
for attrib in self.enum:
|
|
|
|
if attrib in self.XMLExclude:
|
|
|
|
continue
|
|
|
|
val = self.data(attrib)
|
|
|
|
if val or attrib in self.XMLForce:
|
|
|
|
item.set(attrib.name, str(val))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
# Saving revisions
|
|
|
|
rev = self.revisions()
|
|
|
|
for r in rev:
|
|
|
|
revItem = ET.Element("revision")
|
|
|
|
revItem.set("timestamp", str(r[0]))
|
|
|
|
revItem.set("text", r[1])
|
|
|
|
item.append(revItem)
|
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 00:16:39 +13:00
|
|
|
for i in self.childItems:
|
|
|
|
item.append(ET.XML(i.toXML()))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
return ET.tostring(item)
|
2017-11-16 08:33:27 +13:00
|
|
|
|
2017-11-18 00:16:39 +13:00
|
|
|
def toXML_(self):
|
2017-11-16 08:33:27 +13:00
|
|
|
item = ET.Element("outlineItem")
|
|
|
|
|
|
|
|
for attrib in Outline:
|
|
|
|
if attrib in exclude: continue
|
|
|
|
val = self.data(attrib.value)
|
|
|
|
if val or attrib in force:
|
|
|
|
item.set(attrib.name, str(val))
|
|
|
|
|
|
|
|
# Saving revisions
|
|
|
|
rev = self.revisions()
|
|
|
|
for r in rev:
|
|
|
|
revItem = ET.Element("revision")
|
|
|
|
revItem.set("timestamp", str(r[0]))
|
|
|
|
revItem.set("text", r[1])
|
|
|
|
item.append(revItem)
|
|
|
|
|
|
|
|
# Saving lastPath
|
|
|
|
item.set("lastPath", self._lastPath)
|
|
|
|
|
|
|
|
for i in self.childItems:
|
|
|
|
item.append(ET.XML(i.toXML()))
|
|
|
|
|
|
|
|
return ET.tostring(item)
|
|
|
|
|
|
|
|
def setFromXML(self, xml):
|
|
|
|
root = ET.XML(xml)
|
|
|
|
|
|
|
|
for k in root.attrib:
|
|
|
|
if k in Outline.__members__:
|
|
|
|
# if k == Outline.compile:
|
2017-11-16 08:58:12 +13:00
|
|
|
# self.setData(Outline.__members__[k], unicode(root.attrib[k]), Qt.CheckStateRole)
|
2017-11-16 08:33:27 +13:00
|
|
|
# else:
|
2017-11-16 08:58:12 +13:00
|
|
|
self.setData(Outline.__members__[k], str(root.attrib[k]))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
if "lastPath" in root.attrib:
|
|
|
|
self._lastPath = root.attrib["lastPath"]
|
|
|
|
|
|
|
|
# If loading from an old file format, convert to md and remove html markup
|
|
|
|
if self.type() in ["txt", "t2t"]:
|
2017-11-16 08:58:12 +13:00
|
|
|
self.setData(Outline.type, "md")
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
elif self.type() == "html":
|
2017-11-16 08:58:12 +13:00
|
|
|
self.setData(Outline.type, "md")
|
2017-11-16 09:05:48 +13:00
|
|
|
self.setData(Outline.text, HTML2PlainText(self.data(Outline.text)))
|
|
|
|
self.setData(Outline.notes, HTML2PlainText(self.data(Outline.notes)))
|
2017-11-16 08:33:27 +13:00
|
|
|
|
|
|
|
for child in root:
|
|
|
|
if child.tag == "outlineItem":
|
|
|
|
item = outlineItem(self._model, xml=ET.tostring(child), parent=self)
|
|
|
|
elif child.tag == "revision":
|
|
|
|
self.appendRevision(child.attrib["timestamp"], child.attrib["text"])
|
|
|
|
|