diff --git a/manuskript/exporter/basic.py b/manuskript/exporter/basic.py index bc1c1fc..ed052f2 100644 --- a/manuskript/exporter/basic.py +++ b/manuskript/exporter/basic.py @@ -7,7 +7,7 @@ import subprocess from PyQt5.QtCore import QSettings from PyQt5.QtWidgets import QWidget -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.functions import mainWindow diff --git a/manuskript/exporter/manuskript/plainText.py b/manuskript/exporter/manuskript/plainText.py index afeeac4..dc37eaa 100644 --- a/manuskript/exporter/manuskript/plainText.py +++ b/manuskript/exporter/manuskript/plainText.py @@ -6,7 +6,7 @@ from PyQt5.QtWidgets import QPlainTextEdit, qApp, QFrame, QFileDialog from manuskript.exporter.basic import basicFormat from manuskript.functions import mainWindow -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.ui.exporters.manuskript.plainTextSettings import exporterSettings diff --git a/manuskript/importer/folderImporter.py b/manuskript/importer/folderImporter.py index 6789dc2..1dd0835 100644 --- a/manuskript/importer/folderImporter.py +++ b/manuskript/importer/folderImporter.py @@ -3,7 +3,7 @@ import os from manuskript.importer.abstractImporter import abstractImporter -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.enums import Outline from PyQt5.QtWidgets import qApp diff --git a/manuskript/importer/markdownImporter.py b/manuskript/importer/markdownImporter.py index a57b6be..cf0696d 100644 --- a/manuskript/importer/markdownImporter.py +++ b/manuskript/importer/markdownImporter.py @@ -2,7 +2,7 @@ # --!-- coding: utf8 --!-- from manuskript.importer.abstractImporter import abstractImporter -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.enums import Outline from PyQt5.QtWidgets import qApp import re, os diff --git a/manuskript/importer/mindMapImporter.py b/manuskript/importer/mindMapImporter.py index 6d377ca..42e5cfe 100644 --- a/manuskript/importer/mindMapImporter.py +++ b/manuskript/importer/mindMapImporter.py @@ -2,7 +2,7 @@ # --!-- coding: utf8 --!-- from PyQt5.QtWidgets import qApp, QMessageBox -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.enums import Outline from lxml import etree as ET from manuskript.functions import mainWindow diff --git a/manuskript/importer/opmlImporter.py b/manuskript/importer/opmlImporter.py index b7b8b99..fe0cc9b 100644 --- a/manuskript/importer/opmlImporter.py +++ b/manuskript/importer/opmlImporter.py @@ -2,7 +2,7 @@ # --!-- coding: utf8 --!-- from PyQt5.QtWidgets import qApp, QMessageBox -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.enums import Outline from lxml import etree as ET from manuskript.functions import mainWindow diff --git a/manuskript/load_save/version_1.py b/manuskript/load_save/version_1.py index 6015a3e..5ed0bb2 100644 --- a/manuskript/load_save/version_1.py +++ b/manuskript/load_save/version_1.py @@ -24,7 +24,7 @@ from lxml import etree as ET from manuskript.load_save.version_0 import loadFilesFromZip from manuskript.models.characterModel import CharacterInfo -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem try: import zlib # Used with zipfile for compression diff --git a/manuskript/mainWindow.py b/manuskript/mainWindow.py index e1fce64..1b851be 100644 --- a/manuskript/mainWindow.py +++ b/manuskript/mainWindow.py @@ -14,7 +14,7 @@ from manuskript.functions import AUC, wordCount, appPath, findWidgetsOfClass import manuskript.functions as F from manuskript import loadSave from manuskript.models.characterModel import characterModel -from manuskript.models.outlineModel import outlineModel +from manuskript.models import outlineModel from manuskript.models.plotModel import plotModel from manuskript.models.worldModel import worldModel from manuskript.settingsWindow import settingsWindow diff --git a/manuskript/models/__init__.py b/manuskript/models/__init__.py index e69de29..206722f 100644 --- a/manuskript/models/__init__.py +++ b/manuskript/models/__init__.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.models.outlineItem import outlineItem +from manuskript.models.outlineModel import outlineModel diff --git a/manuskript/models/abstractItem.py b/manuskript/models/abstractItem.py new file mode 100644 index 0000000..38d1d49 --- /dev/null +++ b/manuskript/models/abstractItem.py @@ -0,0 +1,679 @@ +#!/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 +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 +import time, os + + +class abstractItem(): + def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None): + + self._data = {} + self.childItems = [] + self._parent = None + self._model = model + self.defaultTextType = None + 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, + # in case it is renamed / removed + + if title: + self._data[Outline.title] = title + + self._data[Outline.type] = _type + self._data[Outline.compile] = Qt.Checked + + if xml is not None: + self.setFromXML(xml) + + if parent: + parent.appendChild(self) + + if ID: + self._data[Outline.ID] = ID + + 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 + + def columnCount(self): + return len(Outline) + + def data(self, column, role=Qt.DisplayRole): + + # print("Data: ", column, role) + + if role == Qt.DisplayRole or role == Qt.EditRole: + # if column == Outline.compile.value: + # return self.data(column, Qt.CheckStateRole) + + if Outline(column) in self._data: + return self._data[Outline(column)] + + elif column == Outline.revisions.value: + return [] + + else: + return "" + + elif role == Qt.DecorationRole and column == Outline.title.value: + if self.customIcon(): + return QIcon.fromTheme(self.data(Outline.customIcon.value)) + 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) + + elif role == Qt.CheckStateRole and column == Outline.compile.value: + # 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() + if column == Outline.wordCount.value and self.isFolder(): + f.setItalic(True) + elif column == Outline.goal.value and self.isFolder() and self.data(Outline.setGoal) == None: + 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]: + print(column, column == Outline.text.value, data, role) + return + + if column == Outline.text.value and self.isFolder(): + # Folder have no text + return + + if column == Outline.goal.value: + self._data[Outline.setGoal] = toInt(data) if toInt(data) > 0 else "" + + # Checking if we will have to recount words + updateWordCount = False + if column in [Outline.wordCount.value, Outline.goal.value, Outline.setGoal.value]: + updateWordCount = not Outline(column) in self._data or self._data[Outline(column)] != data + + # Stuff to do before + if column == Outline.text.value: + self.addRevision() + + # Setting data + self._data[Outline(column)] = data + + # Stuff to do afterwards + if column == Outline.text.value: + wc = wordCount(data) + self.setData(Outline.wordCount.value, wc) + self.emitDataChanged(cols=[Outline.text.value]) # new in 0.5.0 + + if column == Outline.compile.value: + self.emitDataChanged(cols=[Outline.title.value, Outline.compile.value], recursive=True) + + if column == Outline.customIcon.value: + # If custom icon changed, we tell views to update title (so that icons + # will be updated as well) + self.emitDataChanged(cols=[Outline.title.value]) + + if updateWordCount: + self.updateWordCount() + + def updateWordCount(self, emit=True): + """Update word count for item and parents. + If emit is False, no signal is emitted (sometimes cause segfault)""" + if not self.isFolder(): + setGoal = toInt(self.data(Outline.setGoal.value)) + goal = toInt(self.data(Outline.goal.value)) + + if goal != setGoal: + self._data[Outline.goal] = setGoal + if setGoal: + wc = toInt(self.data(Outline.wordCount.value)) + self.setData(Outline.goalPercentage.value, wc / float(setGoal)) + + else: + wc = 0 + for c in self.children(): + wc += toInt(c.data(Outline.wordCount.value)) + self._data[Outline.wordCount] = wc + + setGoal = toInt(self.data(Outline.setGoal.value)) + goal = toInt(self.data(Outline.goal.value)) + + if setGoal: + if goal != setGoal: + self._data[Outline.goal] = setGoal + goal = setGoal + else: + goal = 0 + for c in self.children(): + goal += toInt(c.data(Outline.goal.value)) + self._data[Outline.goal] = goal + + if goal: + self.setData(Outline.goalPercentage.value, wc / float(goal)) + else: + self.setData(Outline.goalPercentage.value, "") + + if emit: + self.emitDataChanged([Outline.goal.value, Outline.setGoal.value, + Outline.wordCount.value, Outline.goalPercentage.value]) + + if self.parent(): + self.parent().updateWordCount(emit) + + 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.data(Outline.ID.value): + child.getUniqueID() + self.updateWordCount() + + 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): + 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(Outline))) + + 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) + + def removeChild(self, row): + """ + Removes child at position `row` and returns it. + @param row: index (int) of the child to remove. + @return: the removed outlineItem + """ + r = self.childItems.pop(row) + # Might be causing segfault when updateWordCount emits dataChanged + self.updateWordCount(emit=False) + return r + + def parent(self): + return self._parent + + def type(self): + return self._data[Outline.type] + + def isFolder(self): + return self._data[Outline.type] == "folder" + + def isText(self): + return self._data[Outline.type] == "md" + + def isMD(self): + return self._data[Outline.type] == "md" + + def isMMD(self): + return self._data[Outline.type] == "md" + + def customIcon(self): + return self.data(Outline.customIcon.value) + + def setCustomIcon(self, customIcon): + self.setData(Outline.customIcon.value, customIcon) + + def text(self): + return self.data(Outline.text.value) + + def compile(self): + if self._data[Outline.compile] in ["0", 0]: + return False + elif self.parent(): + return self.parent().compile() + else: + return True # rootItem always compile + + def title(self): + if Outline.title in self._data: + return self._data[Outline.title] + else: + return "" + + def ID(self): + return self.data(Outline.ID.value) + + def POV(self): + return self.data(Outline.POV.value) + + def status(self): + return self.data(Outline.status.value) + + def label(self): + return self.data(Outline.label.value) + + def path(self): + "Returns path to item as string." + if self.parent().parent(): + return "{} > {}".format(self.parent().path(), 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 stats(self): + wc = self.data(Outline.wordCount.value) + goal = self.data(Outline.goal.value) + progress = self.data(Outline.goalPercentage.value) + if not wc: + wc = 0 + if goal: + return qApp.translate("outlineModel", "{} words / {} ({})").format( + locale.format("%d", wc, grouping=True), + locale.format("%d", goal, grouping=True), + "{}%".format(str(int(progress * 100)))) + else: + return qApp.translate("outlineModel", "{} words").format( + locale.format("%d", wc, grouping=True)) + + def copy(self): + """ + Returns a copy of item, with no parent, and no ID. + """ + item = outlineItem(xml=self.toXML()) + item.setData(Outline.ID.value, None) + return item + + def split(self, splitMark, recursive=True): + """ + Split scene at splitMark. If multiple splitMark, multiple splits. + + If called on a folder and recursive is True, then it is recursively + applied to every children. + """ + if self.isFolder() and recursive: + for c in self.children(): + c.split(splitMark) + + else: + txt = self.text().split(splitMark) + + if len(txt) == 1: + # Mark not found + return False + + else: + + # Stores the new text + self.setData(Outline.text.value, txt[0]) + + k = 1 + for subTxt in txt[1:]: + # Create a copy + item = self.copy() + + # Change title adding _k + item.setData(Outline.title.value, + "{}_{}".format(item.title(), k+1)) + + # Set text + item.setData(Outline.text.value, subTxt) + + # Inserting item + #self.parent().insertChild(self.row()+k, item) + self._model.insertItem(item, self.row()+k, self.parent().index()) + k += 1 + + def splitAt(self, position, length=0): + """ + Splits note at position p. + + If length is bigger than 0, it describes the length of the title, made + from the character following position. + """ + + txt = self.text() + + # Stores the new text + self.setData(Outline.text.value, txt[:position]) + + # Create a copy + item = self.copy() + + # Update title + if length > 0: + title = txt[position:position+length].replace("\n", "") + else: + title = "{}_{}".format(item.title(), 2) + item.setData(Outline.title.value, title) + + # Set text + item.setData(Outline.text.value, txt[position+length:]) + + # Inserting item using the model to signal views + self._model.insertItem(item, self.row()+1, self.parent().index()) + + def mergeWith(self, items, sep="\n\n"): + """ + Merges item with several other items. Merge is basic, it merges only + the text. + + @param items: list of `outlineItem`s. + @param sep: a text added between each item's text. + """ + + # Merges the texts + text = [self.text()] + text.extend([i.text() for i in items]) + self.setData(Outline.text.value, sep.join(text)) + + # Removes other items + self._model.removeIndexes([i.index() for i in items]) + + ############################################################################### + # XML + ############################################################################### + + def toXML(self): + item = ET.Element("outlineItem") + + # 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 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: + # self.setData(Outline.__members__[k].value, unicode(root.attrib[k]), Qt.CheckStateRole) + # else: + self.setData(Outline.__members__[k].value, str(root.attrib[k])) + + 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"]: + self.setData(Outline.type.value, "md") + + elif self.type() == "html": + self.setData(Outline.type.value, "md") + self.setData(Outline.text.value, HTML2PlainText(self.data(Outline.text.value))) + self.setData(Outline.notes.value, HTML2PlainText(self.data(Outline.notes.value))) + + 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"]) + + ############################################################################### + # IDS + ############################################################################### + + def getUniqueID(self, recursive=False): + self.setData(Outline.ID.value, 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.data(Outline.ID.value) + if not _id or _id == "0": + c.getUniqueID() + checkChildren(c) + + checkChildren(self) + + def listAllIDs(self): + IDs = [self.data(Outline.ID.value)] + 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) + + def pathToItem(self): + path = self.data(Outline.ID.value) + if self.parent().parent(): + path = "{}:{}".format(self.parent().pathToItem(), path) + return path + + def findItemsByPOV(self, POV): + "Returns a list of IDs of all subitems whose POV is ``POV``." + lst = [] + if self.POV() == POV: + lst.append(self.ID()) + + for c in self.children(): + lst.extend(c.findItemsByPOV(POV)) + + return lst + + def findItemsContaining(self, text, columns, mainWindow=mainWindow(), caseSensitive=False, recursive=True): + """Returns a list if IDs of all subitems + containing ``text`` in columns ``columns`` + (being a list of int). + """ + lst = self.itemContains(text, columns, mainWindow, caseSensitive) + + if recursive: + for c in self.children(): + lst.extend(c.findItemsContaining(text, columns, mainWindow, caseSensitive)) + + return lst + + def itemContains(self, text, columns, mainWindow=mainWindow(), caseSensitive=False): + lst = [] + text = text.lower() if not caseSensitive else text + for c in columns: + + if c == Outline.POV.value and self.POV(): + c = mainWindow.mdlCharacter.getCharacterByID(self.POV()) + if c: + searchIn = c.name() + else: + searchIn = "" + print("Character POV not found:", self.POV()) + + elif c == Outline.status.value: + searchIn = mainWindow.mdlStatus.item(toInt(self.status()), 0).text() + + elif c == Outline.label.value: + searchIn = mainWindow.mdlLabels.item(toInt(self.label()), 0).text() + + else: + searchIn = self.data(c) + + searchIn = searchIn.lower() if not caseSensitive else searchIn + + if text in searchIn: + if not self.ID() in lst: + lst.append(self.ID()) + + return lst + + ############################################################################### + # REVISIONS + ############################################################################### + + def revisions(self): + return self.data(Outline.revisions.value) + + def appendRevision(self, ts, text): + if not Outline.revisions in self._data: + self._data[Outline.revisions] = [] + + self._data[Outline.revisions].append(( + int(ts), + text)) + + def addRevision(self): + if not settings.revisions["keep"]: + return + + if not Outline.text in self._data: + return + + self.appendRevision( + time.time(), + self._data[Outline.text]) + + if settings.revisions["smartremove"]: + self.cleanRevisions() + + self.emitDataChanged([Outline.revisions.value]) + + def deleteRevision(self, ts): + self._data[Outline.revisions] = [r for r in self._data[Outline.revisions] if r[0] != ts] + self.emitDataChanged([Outline.revisions.value]) + + def clearAllRevisions(self): + self._data[Outline.revisions] = [] + self.emitDataChanged([Outline.revisions.value]) + + def cleanRevisions(self): + "Keep only one some the revisions." + rev = self.revisions() + rev2 = [] + now = time.time() + + rule = settings.revisions["rules"] + + revs = {} + for i in rule: + revs[i] = [] + + for r in rev: + # Have to put the lambda key otherwise cannot order when one element is None + for span in sorted(rule, key=lambda x: x if x else 60 * 60 * 24 * 30 * 365): + if not span or now - r[0] < span: + revs[span].append(r) + break + + for span in revs: + sortedRev = sorted(revs[span], key=lambda x: x[0]) + last = None + for r in sortedRev: + if not last: + rev2.append(r) + last = r[0] + elif r[0] - last >= rule[span]: + rev2.append(r) + last = r[0] + + if rev2 != rev: + self._data[Outline.revisions] = rev2 + self.emitDataChanged([Outline.revisions.value]) diff --git a/manuskript/models/abstractModel.py b/manuskript/models/abstractModel.py new file mode 100644 index 0000000..2bd43a9 --- /dev/null +++ b/manuskript/models/abstractModel.py @@ -0,0 +1,547 @@ +#!/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 +from manuskript.functions import mainWindow, toInt, wordCount +from manuskript.converters import HTML2PlainText +from manuskript.models import outlineItem + +try: + locale.setlocale(locale.LC_ALL, '') +except: + # Invalid locale, but not really a big deal because it's used only for + # number formating + pass +import time, os + + +class abstractModel(QAbstractItemModel): + """ + Abstract model is the base class for all others models we use. + + It's main responsibilities are: + + - Interface with QModelIndex and stuff + - XML Import / Export + - Drag'n'drop + + """ + def __init__(self, parent): + QAbstractItemModel.__init__(self, parent) + + self.rootItem = outlineItem(self, title="Root", ID="0") + + # Stores removed item, in order to remove them on disk when saving, depending on the file format. + self.removed = [] + self._removingRows = False + + def index(self, row, column, parent): + + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + childItem = parentItem.child(row) + if childItem: + return self.createIndex(row, column, childItem) + else: + return QModelIndex() + + def indexFromItem(self, item, column=0): + if item == self.rootItem: + return QModelIndex() + + parent = item.parent() + if not parent: + parent = self.rootItem + + if len(parent.children()) == 0: + return None + + # print(item.title(), [i.title() for i in parent.children()]) + + row = parent.children().index(item) + col = column + return self.createIndex(row, col, item) + + def ID(self, index): + if index.isValid(): + item = index.internalPointer() + return item.ID() + + def findItemsContaining(self, text, columns, caseSensitive=False): + """ + Returns a list of IDs of all items containing `text` + in columns `columns` (being a list of int). + """ + return self.rootItem.findItemsContaining(text, columns, mainWindow(), caseSensitive) + + def getItemByID(self, ID): + def search(item): + if item.ID() == ID: + return item + for c in item.children(): + r = search(c) + if r: + return r + + item = search(self.rootItem) + return item + + def getIndexByID(self, ID): + "Returns the index of item whose ID is `ID`. If none, returns QModelIndex()." + item = self.getItemByID(ID) + if not item: + return QModelIndex() + else: + return self.indexFromItem(item) + + def parent(self, index=QModelIndex()): + if not index.isValid(): + return QModelIndex() + + childItem = index.internalPointer() + parentItem = childItem.parent() + + if parentItem == self.rootItem: + return QModelIndex() + + return self.createIndex(parentItem.row(), 0, parentItem) + + def rowCount(self, parent=QModelIndex()): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + return parentItem.childCount() + + def columnCount(self, parent=QModelIndex()): + if parent.isValid(): + return parent.internalPointer().columnCount() + else: + return self.rootItem.columnCount() + + def data(self, index, role=Qt.DisplayRole): + if not index.isValid(): + return QVariant() + + item = index.internalPointer() + return item.data(index.column(), role) + + def setData(self, index, value, role=Qt.EditRole): + item = index.internalPointer() + if item.data(index.column(), role) != value: + + item.setData(index.column(), value, role) + + # self.dataChanged.emit(index.sibling(index.row(), 0), + # index.sibling(index.row(), max([i.value for i in Outline]))) + # print("Model emit", index.row(), index.column()) + self.dataChanged.emit(index, index) + + if index.column() == Outline.type.value: + # If type changed, then the icon of title changed. + # Some views might be glad to know it. + self.dataChanged.emit(index.sibling(index.row(), Outline.title.value), + index.sibling(index.row(), Outline.title.value)) + + return True + + def headerData(self, section, orientation, role=Qt.DisplayRole): + if orientation == Qt.Horizontal and role in [Qt.DisplayRole, Qt.ToolTipRole]: + if section == Outline.title.value: + return self.tr("Title") + elif section == Outline.POV.value: + return self.tr("POV") + elif section == Outline.label.value: + return self.tr("Label") + elif section == Outline.status.value: + return self.tr("Status") + elif section == Outline.compile.value: + return self.tr("Compile") + elif section == Outline.wordCount.value: + return self.tr("Word count") + elif section == Outline.goal.value: + return self.tr("Goal") + elif section == Outline.goalPercentage.value: + return "%" + else: + return [i.name for i in Outline][section] + + elif role == Qt.SizeHintRole: + if section == Outline.compile.value: + return QSize(40, 30) + elif section == Outline.goalPercentage.value: + return QSize(100, 30) + else: + return QVariant() + else: + return QVariant() + + return True + + def maxLevel(self): + """Returns the max depth of the model.""" + def depth(item, d=-1): + d += 1 + r = d + for c in item.children(): + r = max(r, depth(c, d)) + return r + + d = depth(self.rootItem) + return d + + #################### DRAG AND DROP ######################## + # http://doc.qt.io/qt-5/model-view-programming.html#using-drag-and-drop-with-item-views + + def flags(self, index): + # FIXME when dragging folders, sometimes flags is not called + + flags = QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable + + if index.isValid() and index.internalPointer().isFolder() and index.column() == 0: + flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled + + elif index.isValid() and index.column() == 0: + flags |= Qt.ItemIsDragEnabled + + elif not index.isValid(): + flags |= Qt.ItemIsDropEnabled + + if index.isValid() and index.column() == Outline.compile.value: + flags |= Qt.ItemIsUserCheckable + + if index.column() in [i.value for i in [Outline.wordCount, Outline.goalPercentage]]: + flags &= ~ Qt.ItemIsEditable + + return flags + + def mimeTypes(self): + return ["application/xml"] + + def mimeData(self, indexes): + mimeData = QMimeData() + encodedData = "" + + root = ET.Element("outlineItems") + + for index in indexes: + if index.isValid() and index.column() == 0: + item = ET.XML(index.internalPointer().toXML()) + root.append(item) + + encodedData = ET.tostring(root) + + mimeData.setData("application/xml", encodedData) + return mimeData + + def supportedDropActions(self): + + return Qt.CopyAction | Qt.MoveAction + + def canDropMimeData(self, data, action, row, column, parent): + """Ensures that we are not droping an item into itself.""" + + if not data.hasFormat("application/xml"): + return False + + if column > 0: + return False + + # # Gets encoded mime data to retrieve the item + items = self.decodeMimeData(data) + if items is None: + return False + + # We check if parent is not a child of one of the items + if self.isParentAChildOfItems(parent, items): + return False + + return True + + def isParentAChildOfItems(self, parent, items): + """ + Takes a parent index, and a list of outlineItems items. Check whether + parent is in a child of one of the items. + Return True in that case, False if not. + """ + + # Get the parent item + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + for item in items: + # Get parentItem's parents IDs in a list + path = parentItem.pathID() # path to item in the form [(ID, title), ...] + path = [ID for ID, title in path] + # Is item in the path? It would mean that it tries to get dropped + # as a children of himself. + if item.ID() in path: + return True + + return False + + def decodeMimeData(self, data): + if not data.hasFormat("application/xml"): + return None + encodedData = bytes(data.data("application/xml")).decode() + root = ET.XML(encodedData) + if root is None: + return None + + if root.tag != "outlineItems": + return None + + items = [] + for child in root: + if child.tag == "outlineItem": + item = outlineItem(xml=ET.tostring(child)) + items.append(item) + + # We remove every item whose parent is also in items, otherwise it gets + # duplicated. (https://github.com/olivierkes/manuskript/issues/169) + # For example if selecting: + # - Parent + # - Child + # And draging them, items encoded in mime data are: [Parent, Child], + # but Child is already contained in Parent, so if we do nothing we end + # up with: + # - Parent + # - Child + # - Child + + newItems = items[:] + IDs = [i.ID() for i in items] + + def checkIfChildIsPresent(item): + # Recursively check every children of item, to see if any is in + # the list of items to copy. If so, we remove it from the list. + for c in item.children(): + # We check if children is in the selection + # and if it hasn't been removed yet + if c.ID() in IDs and c.ID() in [i.ID() for i in newItems]: + # Remove item by ID + newItems.remove([i for i in newItems if i.ID() == c.ID()][0]) + checkIfChildIsPresent(c) + + for i in items: + checkIfChildIsPresent(i) + + items = newItems + + return items + + def dropMimeData(self, data, action, row, column, parent): + + if action == Qt.IgnoreAction: + return True # What is that? + + if action == Qt.MoveAction: + # Strangely, on some cases, we get a call to dropMimeData though + # self.canDropMimeData returned False. + # See https://github.com/olivierkes/manuskript/issues/169 to reproduce. + # So we double check for safety. + if not self.canDropMimeData(data, action, row, column, parent): + return False + + items = self.decodeMimeData(data) + if items is None: + return False + + if column > 0: + column = 0 + + if row != -1: + beginRow = row + elif parent.isValid(): + beginRow = self.rowCount(parent) + 1 + else: + beginRow = self.rowCount() + 1 + + if action == Qt.CopyAction: + # Behavior if parent is a text item + # For example, we select a text and do: CTRL+C CTRL+V + if parent.isValid() and not parent.internalPointer().isFolder(): + # We insert copy in parent folder, just below + beginRow = parent.row() + 1 + parent = parent.parent() + + if parent.isValid() and parent.internalPointer().isFolder(): + while self.isParentAChildOfItems(parent, items): + # We are copying a folder on itself. Assume duplicates. + # Copy not in, but next to + beginRow = parent.row() + 1 + parent = parent.parent() + + if not items: + return False + + # In case of copy actions, items might be duplicates, so we need new IDs. + # But they might not be, if we cut, then paste. Paste is a Copy Action. + # The first paste would not need new IDs. But subsequent ones will. + if action == Qt.CopyAction: + IDs = self.rootItem.listAllIDs() + + for item in items: + if item.ID() in IDs: + # Recursively remove ID. So will get a new one when inserted. + def stripID(item): + item.setData(Outline.ID.value, None) + for c in item.children(): + stripID(c) + + stripID(item) + + r = self.insertItems(items, beginRow, parent) + + return r + + ################# ADDING AND REMOVING ################# + + def insertItem(self, item, row, parent=QModelIndex()): + return self.insertItems([item], row, parent) + + def insertItems(self, items, row, parent=QModelIndex()): + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + if parent.isValid() and parent.column() != 0: + parent = parentItem.index() + + # Insert only if parent is folder + if parentItem.isFolder(): + self.beginInsertRows(parent, row, row + len(items) - 1) + + for i in items: + parentItem.insertChild(row + items.index(i), i) + + self.endInsertRows() + + return True + + else: + return False + + def appendItem(self, item, parent=QModelIndex()): + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + if parent.isValid() and parent.column() != 0: + parent = parentItem.index() + + # If parent is folder, write into + if parentItem.isFolder(): + self.insertItem(item, self.rowCount(parent), parent) + + # If parent is not folder, write next to + else: + self.insertItem(item, parent.row() + 1, parent.parent()) + + def removeIndex(self, index): + item = index.internalPointer() + self.removeRow(item.row(), index.parent()) + + def removeIndexes(self, indexes): + levels = {} + for i in indexes: + item = i.internalPointer() + level = item.level() + if not level in levels: + levels[level] = [] + levels[level].append([i.row(), i]) + + # Sort by level then by row + for l in reversed(sorted(levels.keys())): + rows = levels[l] + + rows = list(reversed(sorted(rows, key=lambda x: x[0]))) + for r in rows: + self.removeIndex(r[1]) + + def removeRow(self, row, parent=QModelIndex()): + return self.removeRows(row, 1, parent) + + def removeRows(self, row, count, parent=QModelIndex()): + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + self._removingRows = True # Views that are updating can easily know + # if this is due to row removal. + self.beginRemoveRows(parent, row, row + count - 1) + for i in range(count): + item = parentItem.removeChild(row) + self.removed.append(item) + + self._removingRows = False + self.endRemoveRows() + return True + + # def insertRow(self, row, item, parent=QModelIndex()): + # self.beginInsertRows(parent, row, row) + + # if not parent.isValid(): + # parentItem = self.rootItem + # else: + # parentItem = parent.internalPointer() + + # parentItem.insertChild(row, item) + + # self.endInsertRows() + + ################# XML / saving / loading ################# + + def saveToXML(self, xml=None): + "If xml (filename) is given, saves the items to xml. Otherwise returns as string." + root = ET.XML(self.rootItem.toXML()) + if xml: + ET.ElementTree(root).write(xml, encoding="UTF-8", xml_declaration=True, pretty_print=True) + else: + return ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True) + + def loadFromXML(self, xml, fromString=False): + "Load from xml. Assume that xml is a filename. If fromString=True, xml is the content." + if not fromString: + root = ET.parse(xml) + else: + root = ET.fromstring(xml) + + self.rootItem = outlineItem(model=self, xml=ET.tostring(root), ID="0") + self.rootItem.checkIDs() + + def indexFromPath(self, path): + path = path.split(",") + item = self.rootItem + for p in path: + if p != "" and int(p) < item.childCount(): + item = item.child(int(p)) + return self.indexFromItem(item) diff --git a/manuskript/models/outlineItem.py b/manuskript/models/outlineItem.py new file mode 100644 index 0000000..79d730e --- /dev/null +++ b/manuskript/models/outlineItem.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +# --!-- coding: utf8 --!-- + +from manuskript.models.abstractItem import abstractItem + + +class outlineItem(abstractItem): + def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None): + abstractItem.__init__(self, model, title, _type, xml, parent, ID) diff --git a/manuskript/models/outlineModel.py b/manuskript/models/outlineModel.py index 4ce6772..63494e2 100644 --- a/manuskript/models/outlineModel.py +++ b/manuskript/models/outlineModel.py @@ -1,1198 +1,13 @@ #!/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 -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 -import time, os +from manuskript.models.abstractModel import abstractModel -class outlineModel(QAbstractItemModel): +class outlineModel(abstractModel): def __init__(self, parent): - QAbstractItemModel.__init__(self, parent) - - self.rootItem = outlineItem(self, title="Root", ID="0") - - # Stores removed item, in order to remove them on disk when saving, depending on the file format. - self.removed = [] - self._removingRows = False - - def index(self, row, column, parent): - - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - childItem = parentItem.child(row) - if childItem: - return self.createIndex(row, column, childItem) - else: - return QModelIndex() - - def indexFromItem(self, item, column=0): - if item == self.rootItem: - return QModelIndex() - - parent = item.parent() - if not parent: - parent = self.rootItem - - if len(parent.children()) == 0: - return None - - # print(item.title(), [i.title() for i in parent.children()]) - - row = parent.children().index(item) - col = column - return self.createIndex(row, col, item) - - def ID(self, index): - if index.isValid(): - item = index.internalPointer() - return item.ID() + abstractModel.__init__(self, parent) def findItemsByPOV(self, POV): "Returns a list of IDs of all items whose POV is ``POV``." return self.rootItem.findItemsByPOV(POV) - - def findItemsContaining(self, text, columns, caseSensitive=False): - """Returns a list of IDs of all items containing ``text`` - in columns ``columns`` (being a list of int).""" - return self.rootItem.findItemsContaining(text, columns, mainWindow(), caseSensitive) - - def getItemByID(self, ID): - def search(item): - if item.ID() == ID: - return item - for c in item.children(): - r = search(c) - if r: - return r - - item = search(self.rootItem) - return item - - def getIndexByID(self, ID): - "Returns the index of item whose ID is ``ID``. If none, returns QModelIndex()." - item = self.getItemByID(ID) - if not item: - return QModelIndex() - else: - return self.indexFromItem(item) - - def parent(self, index=QModelIndex()): - if not index.isValid(): - return QModelIndex() - - childItem = index.internalPointer() - # print(childItem.title()) - parentItem = childItem.parent() - # try: - # parentItem = childItem.parent() - # except AttributeError: - # import traceback, sys - # print(traceback.print_exc()) - # print(sys.exc_info()[0]) - # return QModelIndex() - - if parentItem == self.rootItem: - return QModelIndex() - - return self.createIndex(parentItem.row(), 0, parentItem) - - def rowCount(self, parent=QModelIndex()): - if parent.column() > 0: - return 0 - - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - return parentItem.childCount() - - def columnCount(self, parent=QModelIndex()): - if parent.isValid(): - return parent.internalPointer().columnCount() - else: - return self.rootItem.columnCount() - - def data(self, index, role=Qt.DisplayRole): - if not index.isValid(): - return QVariant() - - item = index.internalPointer() - return item.data(index.column(), role) - - def setData(self, index, value, role=Qt.EditRole): - item = index.internalPointer() - if item.data(index.column(), role) != value: - - item.setData(index.column(), value, role) - - # self.dataChanged.emit(index.sibling(index.row(), 0), - # index.sibling(index.row(), max([i.value for i in Outline]))) - # print("Model emit", index.row(), index.column()) - self.dataChanged.emit(index, index) - - if index.column() == Outline.type.value: - # If type changed, then the icon of title changed. - # Some views might be glad to know it. - self.dataChanged.emit(index.sibling(index.row(), Outline.title.value), - index.sibling(index.row(), Outline.title.value)) - - return True - - def headerData(self, section, orientation, role=Qt.DisplayRole): - if orientation == Qt.Horizontal and role in [Qt.DisplayRole, Qt.ToolTipRole]: - if section == Outline.title.value: - return self.tr("Title") - elif section == Outline.POV.value: - return self.tr("POV") - elif section == Outline.label.value: - return self.tr("Label") - elif section == Outline.status.value: - return self.tr("Status") - elif section == Outline.compile.value: - return self.tr("Compile") - elif section == Outline.wordCount.value: - return self.tr("Word count") - elif section == Outline.goal.value: - return self.tr("Goal") - elif section == Outline.goalPercentage.value: - return "%" - else: - return [i.name for i in Outline][section] - - elif role == Qt.SizeHintRole: - if section == Outline.compile.value: - return QSize(40, 30) - elif section == Outline.goalPercentage.value: - return QSize(100, 30) - else: - return QVariant() - else: - return QVariant() - - return True - - def maxLevel(self): - """Returns the max depth of the model.""" - def depth(item, d=-1): - d += 1 - r = d - for c in item.children(): - r = max(r, depth(c, d)) - return r - - d = depth(self.rootItem) - return d - - #################### DRAG AND DROP ######################## - # http://doc.qt.io/qt-5/model-view-programming.html#using-drag-and-drop-with-item-views - - def flags(self, index): - # FIXME when dragging folders, sometimes flags is not called - - flags = QAbstractItemModel.flags(self, index) | Qt.ItemIsEditable - - if index.isValid() and index.internalPointer().isFolder() and index.column() == 0: - flags |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled - - elif index.isValid() and index.column() == 0: - flags |= Qt.ItemIsDragEnabled - - elif not index.isValid(): - flags |= Qt.ItemIsDropEnabled - - if index.isValid() and index.column() == Outline.compile.value: - flags |= Qt.ItemIsUserCheckable - - if index.column() in [i.value for i in [Outline.wordCount, Outline.goalPercentage]]: - flags &= ~ Qt.ItemIsEditable - - return flags - - def mimeTypes(self): - return ["application/xml"] - - def mimeData(self, indexes): - mimeData = QMimeData() - encodedData = "" - - root = ET.Element("outlineItems") - - for index in indexes: - if index.isValid() and index.column() == 0: - item = ET.XML(index.internalPointer().toXML()) - root.append(item) - - encodedData = ET.tostring(root) - - mimeData.setData("application/xml", encodedData) - return mimeData - - def supportedDropActions(self): - - return Qt.CopyAction | Qt.MoveAction - - def canDropMimeData(self, data, action, row, column, parent): - """Ensures that we are not droping an item into itself.""" - - if not data.hasFormat("application/xml"): - return False - - if column > 0: - return False - - # # Gets encoded mime data to retrieve the item - items = self.decodeMimeData(data) - if items is None: - return False - - # We check if parent is not a child of one of the items - if self.isParentAChildOfItems(parent, items): - return False - - return True - - def isParentAChildOfItems(self, parent, items): - """ - Takes a parent index, and a list of outlineItems items. Check whether - parent is in a child of one of the items. - Return True in that case, False if not. - """ - - # Get the parent item - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - for item in items: - # Get parentItem's parents IDs in a list - path = parentItem.pathID() # path to item in the form [(ID, title), ...] - path = [ID for ID, title in path] - # Is item in the path? It would mean that it tries to get dropped - # as a children of himself. - if item.ID() in path: - return True - - return False - - def decodeMimeData(self, data): - if not data.hasFormat("application/xml"): - return None - encodedData = bytes(data.data("application/xml")).decode() - root = ET.XML(encodedData) - if root is None: - return None - - if root.tag != "outlineItems": - return None - - items = [] - for child in root: - if child.tag == "outlineItem": - item = outlineItem(xml=ET.tostring(child)) - items.append(item) - - # We remove every item whose parent is also in items, otherwise it gets - # duplicated. (https://github.com/olivierkes/manuskript/issues/169) - # For example if selecting: - # - Parent - # - Child - # And draging them, items encoded in mime data are: [Parent, Child], - # but Child is already contained in Parent, so if we do nothing we end - # up with: - # - Parent - # - Child - # - Child - - newItems = items[:] - IDs = [i.ID() for i in items] - - def checkIfChildIsPresent(item): - # Recursively check every children of item, to see if any is in - # the list of items to copy. If so, we remove it from the list. - for c in item.children(): - # We check if children is in the selection - # and if it hasn't been removed yet - if c.ID() in IDs and c.ID() in [i.ID() for i in newItems]: - # Remove item by ID - newItems.remove([i for i in newItems if i.ID() == c.ID()][0]) - checkIfChildIsPresent(c) - - for i in items: - checkIfChildIsPresent(i) - - items = newItems - - return items - - def dropMimeData(self, data, action, row, column, parent): - - if action == Qt.IgnoreAction: - return True # What is that? - - if action == Qt.MoveAction: - # Strangely, on some cases, we get a call to dropMimeData though - # self.canDropMimeData returned False. - # See https://github.com/olivierkes/manuskript/issues/169 to reproduce. - # So we double check for safety. - if not self.canDropMimeData(data, action, row, column, parent): - return False - - items = self.decodeMimeData(data) - if items is None: - return False - - if column > 0: - column = 0 - - if row != -1: - beginRow = row - elif parent.isValid(): - beginRow = self.rowCount(parent) + 1 - else: - beginRow = self.rowCount() + 1 - - if action == Qt.CopyAction: - # Behavior if parent is a text item - # For example, we select a text and do: CTRL+C CTRL+V - if parent.isValid() and not parent.internalPointer().isFolder(): - # We insert copy in parent folder, just below - beginRow = parent.row() + 1 - parent = parent.parent() - - if parent.isValid() and parent.internalPointer().isFolder(): - while self.isParentAChildOfItems(parent, items): - # We are copying a folder on itself. Assume duplicates. - # Copy not in, but next to - beginRow = parent.row() + 1 - parent = parent.parent() - - if not items: - return False - - # In case of copy actions, items might be duplicates, so we need new IDs. - # But they might not be, if we cut, then paste. Paste is a Copy Action. - # The first paste would not need new IDs. But subsequent ones will. - if action == Qt.CopyAction: - IDs = self.rootItem.listAllIDs() - - for item in items: - if item.ID() in IDs: - # Recursively remove ID. So will get a new one when inserted. - def stripID(item): - item.setData(Outline.ID.value, None) - for c in item.children(): - stripID(c) - - stripID(item) - - r = self.insertItems(items, beginRow, parent) - - return r - - ################# ADDING AND REMOVING ################# - - def insertItem(self, item, row, parent=QModelIndex()): - return self.insertItems([item], row, parent) - - def insertItems(self, items, row, parent=QModelIndex()): - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - if parent.isValid() and parent.column() != 0: - parent = parentItem.index() - - # Insert only if parent is folder - if parentItem.isFolder(): - self.beginInsertRows(parent, row, row + len(items) - 1) - - for i in items: - parentItem.insertChild(row + items.index(i), i) - - self.endInsertRows() - - return True - - else: - return False - - def appendItem(self, item, parent=QModelIndex()): - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - if parent.isValid() and parent.column() != 0: - parent = parentItem.index() - - # If parent is folder, write into - if parentItem.isFolder(): - self.insertItem(item, self.rowCount(parent), parent) - - # If parent is not folder, write next to - else: - self.insertItem(item, parent.row() + 1, parent.parent()) - - def removeIndex(self, index): - item = index.internalPointer() - self.removeRow(item.row(), index.parent()) - - def removeIndexes(self, indexes): - levels = {} - for i in indexes: - item = i.internalPointer() - level = item.level() - if not level in levels: - levels[level] = [] - levels[level].append([i.row(), i]) - - # Sort by level then by row - for l in reversed(sorted(levels.keys())): - rows = levels[l] - - rows = list(reversed(sorted(rows, key=lambda x: x[0]))) - for r in rows: - self.removeIndex(r[1]) - - def removeRow(self, row, parent=QModelIndex()): - return self.removeRows(row, 1, parent) - - def removeRows(self, row, count, parent=QModelIndex()): - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - self._removingRows = True # Views that are updating can easily know - # if this is due to row removal. - self.beginRemoveRows(parent, row, row + count - 1) - for i in range(count): - item = parentItem.removeChild(row) - self.removed.append(item) - - self._removingRows = False - self.endRemoveRows() - return True - - # def insertRow(self, row, item, parent=QModelIndex()): - # self.beginInsertRows(parent, row, row) - - # if not parent.isValid(): - # parentItem = self.rootItem - # else: - # parentItem = parent.internalPointer() - - # parentItem.insertChild(row, item) - - # self.endInsertRows() - - ################# XML / saving / loading ################# - - def saveToXML(self, xml=None): - "If xml (filename) is given, saves the items to xml. Otherwise returns as string." - root = ET.XML(self.rootItem.toXML()) - if xml: - ET.ElementTree(root).write(xml, encoding="UTF-8", xml_declaration=True, pretty_print=True) - else: - return ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True) - - def loadFromXML(self, xml, fromString=False): - "Load from xml. Assume that xml is a filename. If fromString=True, xml is the content." - if not fromString: - root = ET.parse(xml) - else: - root = ET.fromstring(xml) - - self.rootItem = outlineItem(model=self, xml=ET.tostring(root), ID="0") - self.rootItem.checkIDs() - - def indexFromPath(self, path): - path = path.split(",") - item = self.rootItem - for p in path: - if p != "" and int(p) < item.childCount(): - item = item.child(int(p)) - return self.indexFromItem(item) - - -class outlineItem(): - def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None): - - self._data = {} - self.childItems = [] - self._parent = None - self._model = model - self.defaultTextType = None - 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, - # in case it is renamed / removed - - if title: - self._data[Outline.title] = title - - self._data[Outline.type] = _type - self._data[Outline.compile] = Qt.Checked - - if xml is not None: - self.setFromXML(xml) - - if parent: - parent.appendChild(self) - - if ID: - self._data[Outline.ID] = ID - - 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 - - def columnCount(self): - return len(Outline) - - def data(self, column, role=Qt.DisplayRole): - - # print("Data: ", column, role) - - if role == Qt.DisplayRole or role == Qt.EditRole: - # if column == Outline.compile.value: - # return self.data(column, Qt.CheckStateRole) - - if Outline(column) in self._data: - return self._data[Outline(column)] - - elif column == Outline.revisions.value: - return [] - - else: - return "" - - elif role == Qt.DecorationRole and column == Outline.title.value: - if self.customIcon(): - return QIcon.fromTheme(self.data(Outline.customIcon.value)) - 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) - - elif role == Qt.CheckStateRole and column == Outline.compile.value: - # 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() - if column == Outline.wordCount.value and self.isFolder(): - f.setItalic(True) - elif column == Outline.goal.value and self.isFolder() and self.data(Outline.setGoal) == None: - 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]: - print(column, column == Outline.text.value, data, role) - return - - if column == Outline.text.value and self.isFolder(): - # Folder have no text - return - - if column == Outline.goal.value: - self._data[Outline.setGoal] = toInt(data) if toInt(data) > 0 else "" - - # Checking if we will have to recount words - updateWordCount = False - if column in [Outline.wordCount.value, Outline.goal.value, Outline.setGoal.value]: - updateWordCount = not Outline(column) in self._data or self._data[Outline(column)] != data - - # Stuff to do before - if column == Outline.text.value: - self.addRevision() - - # Setting data - self._data[Outline(column)] = data - - # Stuff to do afterwards - if column == Outline.text.value: - wc = wordCount(data) - self.setData(Outline.wordCount.value, wc) - self.emitDataChanged(cols=[Outline.text.value]) # new in 0.5.0 - - if column == Outline.compile.value: - self.emitDataChanged(cols=[Outline.title.value, Outline.compile.value], recursive=True) - - if column == Outline.customIcon.value: - # If custom icon changed, we tell views to update title (so that icons - # will be updated as well) - self.emitDataChanged(cols=[Outline.title.value]) - - if updateWordCount: - self.updateWordCount() - - def updateWordCount(self, emit=True): - """Update word count for item and parents. - If emit is False, no signal is emitted (sometimes cause segfault)""" - if not self.isFolder(): - setGoal = toInt(self.data(Outline.setGoal.value)) - goal = toInt(self.data(Outline.goal.value)) - - if goal != setGoal: - self._data[Outline.goal] = setGoal - if setGoal: - wc = toInt(self.data(Outline.wordCount.value)) - self.setData(Outline.goalPercentage.value, wc / float(setGoal)) - - else: - wc = 0 - for c in self.children(): - wc += toInt(c.data(Outline.wordCount.value)) - self._data[Outline.wordCount] = wc - - setGoal = toInt(self.data(Outline.setGoal.value)) - goal = toInt(self.data(Outline.goal.value)) - - if setGoal: - if goal != setGoal: - self._data[Outline.goal] = setGoal - goal = setGoal - else: - goal = 0 - for c in self.children(): - goal += toInt(c.data(Outline.goal.value)) - self._data[Outline.goal] = goal - - if goal: - self.setData(Outline.goalPercentage.value, wc / float(goal)) - else: - self.setData(Outline.goalPercentage.value, "") - - if emit: - self.emitDataChanged([Outline.goal.value, Outline.setGoal.value, - Outline.wordCount.value, Outline.goalPercentage.value]) - - if self.parent(): - self.parent().updateWordCount(emit) - - 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.data(Outline.ID.value): - child.getUniqueID() - self.updateWordCount() - - 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): - 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(Outline))) - - 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) - - def removeChild(self, row): - """ - Removes child at position `row` and returns it. - @param row: index (int) of the child to remove. - @return: the removed outlineItem - """ - r = self.childItems.pop(row) - # Might be causing segfault when updateWordCount emits dataChanged - self.updateWordCount(emit=False) - return r - - def parent(self): - return self._parent - - def type(self): - return self._data[Outline.type] - - def isFolder(self): - return self._data[Outline.type] == "folder" - - def isText(self): - return self._data[Outline.type] == "md" - - def isMD(self): - return self._data[Outline.type] == "md" - - def isMMD(self): - return self._data[Outline.type] == "md" - - def customIcon(self): - return self.data(Outline.customIcon.value) - - def setCustomIcon(self, customIcon): - self.setData(Outline.customIcon.value, customIcon) - - def text(self): - return self.data(Outline.text.value) - - def compile(self): - if self._data[Outline.compile] in ["0", 0]: - return False - elif self.parent(): - return self.parent().compile() - else: - return True # rootItem always compile - - def title(self): - if Outline.title in self._data: - return self._data[Outline.title] - else: - return "" - - def ID(self): - return self.data(Outline.ID.value) - - def POV(self): - return self.data(Outline.POV.value) - - def status(self): - return self.data(Outline.status.value) - - def label(self): - return self.data(Outline.label.value) - - def path(self): - "Returns path to item as string." - if self.parent().parent(): - return "{} > {}".format(self.parent().path(), 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 stats(self): - wc = self.data(Outline.wordCount.value) - goal = self.data(Outline.goal.value) - progress = self.data(Outline.goalPercentage.value) - if not wc: - wc = 0 - if goal: - return qApp.translate("outlineModel", "{} words / {} ({})").format( - locale.format("%d", wc, grouping=True), - locale.format("%d", goal, grouping=True), - "{}%".format(str(int(progress * 100)))) - else: - return qApp.translate("outlineModel", "{} words").format( - locale.format("%d", wc, grouping=True)) - - def copy(self): - """ - Returns a copy of item, with no parent, and no ID. - """ - item = outlineItem(xml=self.toXML()) - item.setData(Outline.ID.value, None) - return item - - def split(self, splitMark, recursive=True): - """ - Split scene at splitMark. If multiple splitMark, multiple splits. - - If called on a folder and recursive is True, then it is recursively - applied to every children. - """ - if self.isFolder() and recursive: - for c in self.children(): - c.split(splitMark) - - else: - txt = self.text().split(splitMark) - - if len(txt) == 1: - # Mark not found - return False - - else: - - # Stores the new text - self.setData(Outline.text.value, txt[0]) - - k = 1 - for subTxt in txt[1:]: - # Create a copy - item = self.copy() - - # Change title adding _k - item.setData(Outline.title.value, - "{}_{}".format(item.title(), k+1)) - - # Set text - item.setData(Outline.text.value, subTxt) - - # Inserting item - #self.parent().insertChild(self.row()+k, item) - self._model.insertItem(item, self.row()+k, self.parent().index()) - k += 1 - - def splitAt(self, position, length=0): - """ - Splits note at position p. - - If length is bigger than 0, it describes the length of the title, made - from the character following position. - """ - - txt = self.text() - - # Stores the new text - self.setData(Outline.text.value, txt[:position]) - - # Create a copy - item = self.copy() - - # Update title - if length > 0: - title = txt[position:position+length].replace("\n", "") - else: - title = "{}_{}".format(item.title(), 2) - item.setData(Outline.title.value, title) - - # Set text - item.setData(Outline.text.value, txt[position+length:]) - - # Inserting item using the model to signal views - self._model.insertItem(item, self.row()+1, self.parent().index()) - - def mergeWith(self, items, sep="\n\n"): - """ - Merges item with several other items. Merge is basic, it merges only - the text. - - @param items: list of `outlineItem`s. - @param sep: a text added between each item's text. - """ - - # Merges the texts - text = [self.text()] - text.extend([i.text() for i in items]) - self.setData(Outline.text.value, sep.join(text)) - - # Removes other items - self._model.removeIndexes([i.index() for i in items]) - - ############################################################################### - # XML - ############################################################################### - - def toXML(self): - item = ET.Element("outlineItem") - - # 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 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: - # self.setData(Outline.__members__[k].value, unicode(root.attrib[k]), Qt.CheckStateRole) - # else: - self.setData(Outline.__members__[k].value, str(root.attrib[k])) - - 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"]: - self.setData(Outline.type.value, "md") - - elif self.type() == "html": - self.setData(Outline.type.value, "md") - self.setData(Outline.text.value, HTML2PlainText(self.data(Outline.text.value))) - self.setData(Outline.notes.value, HTML2PlainText(self.data(Outline.notes.value))) - - 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"]) - - ############################################################################### - # IDS - ############################################################################### - - def getUniqueID(self, recursive=False): - self.setData(Outline.ID.value, 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.data(Outline.ID.value) - if not _id or _id == "0": - c.getUniqueID() - checkChildren(c) - - checkChildren(self) - - def listAllIDs(self): - IDs = [self.data(Outline.ID.value)] - 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) - - def pathToItem(self): - path = self.data(Outline.ID.value) - if self.parent().parent(): - path = "{}:{}".format(self.parent().pathToItem(), path) - return path - - def findItemsByPOV(self, POV): - "Returns a list of IDs of all subitems whose POV is ``POV``." - lst = [] - if self.POV() == POV: - lst.append(self.ID()) - - for c in self.children(): - lst.extend(c.findItemsByPOV(POV)) - - return lst - - def findItemsContaining(self, text, columns, mainWindow=mainWindow(), caseSensitive=False, recursive=True): - """Returns a list if IDs of all subitems - containing ``text`` in columns ``columns`` - (being a list of int). - """ - lst = self.itemContains(text, columns, mainWindow, caseSensitive) - - if recursive: - for c in self.children(): - lst.extend(c.findItemsContaining(text, columns, mainWindow, caseSensitive)) - - return lst - - def itemContains(self, text, columns, mainWindow=mainWindow(), caseSensitive=False): - lst = [] - text = text.lower() if not caseSensitive else text - for c in columns: - - if c == Outline.POV.value and self.POV(): - c = mainWindow.mdlCharacter.getCharacterByID(self.POV()) - if c: - searchIn = c.name() - else: - searchIn = "" - print("Character POV not found:", self.POV()) - - elif c == Outline.status.value: - searchIn = mainWindow.mdlStatus.item(toInt(self.status()), 0).text() - - elif c == Outline.label.value: - searchIn = mainWindow.mdlLabels.item(toInt(self.label()), 0).text() - - else: - searchIn = self.data(c) - - searchIn = searchIn.lower() if not caseSensitive else searchIn - - if text in searchIn: - if not self.ID() in lst: - lst.append(self.ID()) - - return lst - - ############################################################################### - # REVISIONS - ############################################################################### - - def revisions(self): - return self.data(Outline.revisions.value) - - def appendRevision(self, ts, text): - if not Outline.revisions in self._data: - self._data[Outline.revisions] = [] - - self._data[Outline.revisions].append(( - int(ts), - text)) - - def addRevision(self): - if not settings.revisions["keep"]: - return - - if not Outline.text in self._data: - return - - self.appendRevision( - time.time(), - self._data[Outline.text]) - - if settings.revisions["smartremove"]: - self.cleanRevisions() - - self.emitDataChanged([Outline.revisions.value]) - - def deleteRevision(self, ts): - self._data[Outline.revisions] = [r for r in self._data[Outline.revisions] if r[0] != ts] - self.emitDataChanged([Outline.revisions.value]) - - def clearAllRevisions(self): - self._data[Outline.revisions] = [] - self.emitDataChanged([Outline.revisions.value]) - - def cleanRevisions(self): - "Keep only one some the revisions." - rev = self.revisions() - rev2 = [] - now = time.time() - - rule = settings.revisions["rules"] - - revs = {} - for i in rule: - revs[i] = [] - - for r in rev: - # Have to put the lambda key otherwise cannot order when one element is None - for span in sorted(rule, key=lambda x: x if x else 60 * 60 * 24 * 30 * 365): - if not span or now - r[0] < span: - revs[span].append(r) - break - - for span in revs: - sortedRev = sorted(revs[span], key=lambda x: x[0]) - last = None - for r in sortedRev: - if not last: - rev2.append(r) - last = r[0] - elif r[0] - last >= rule[span]: - rev2.append(r) - last = r[0] - - if rev2 != rev: - self._data[Outline.revisions] = rev2 - self.emitDataChanged([Outline.revisions.value]) diff --git a/manuskript/ui/editors/textFormat.py b/manuskript/ui/editors/textFormat.py index 2041b5b..c9c1da8 100644 --- a/manuskript/ui/editors/textFormat.py +++ b/manuskript/ui/editors/textFormat.py @@ -4,7 +4,7 @@ from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QWidget, QAction from manuskript.enums import Outline -from manuskript.models.outlineModel import outlineModel +from manuskript.models import outlineModel from manuskript.ui.editors.textFormat_ui import Ui_textFormat diff --git a/manuskript/ui/importers/importer.py b/manuskript/ui/importers/importer.py index 5d68f9d..73e5fcc 100644 --- a/manuskript/ui/importers/importer.py +++ b/manuskript/ui/importers/importer.py @@ -12,7 +12,7 @@ from manuskript.ui.importers.importer_ui import Ui_importer from manuskript.ui.importers.generalSettings import generalSettings from manuskript.ui import style from manuskript import importer -from manuskript.models.outlineModel import outlineModel, outlineItem +from manuskript.models import outlineModel, outlineItem from manuskript.enums import Outline from manuskript.exporter.pandoc import pandocExporter diff --git a/manuskript/ui/views/outlineBasics.py b/manuskript/ui/views/outlineBasics.py index ccd655c..e1c2a73 100644 --- a/manuskript/ui/views/outlineBasics.py +++ b/manuskript/ui/views/outlineBasics.py @@ -10,7 +10,7 @@ from manuskript import settings from manuskript.enums import Outline from manuskript.functions import mainWindow, statusMessage from manuskript.functions import toInt, customIcons -from manuskript.models.outlineModel import outlineItem +from manuskript.models import outlineItem from manuskript.ui.tools.splitDialog import splitDialog diff --git a/manuskript/ui/views/textEditView.py b/manuskript/ui/views/textEditView.py index bb4a6c4..abdbef0 100644 --- a/manuskript/ui/views/textEditView.py +++ b/manuskript/ui/views/textEditView.py @@ -10,7 +10,7 @@ from manuskript import settings from manuskript.enums import Outline from manuskript.functions import AUC, themeIcon from manuskript.functions import toString -from manuskript.models.outlineModel import outlineModel +from manuskript.models import outlineModel from manuskript.ui.editors.MDFunctions import MDFormatSelection from manuskript.ui.editors.MMDHighlighter import MMDHighlighter from manuskript.ui.editors.basicHighlighter import basicHighlighter diff --git a/manuskript/ui/welcome.py b/manuskript/ui/welcome.py index 9ca5579..d147061 100644 --- a/manuskript/ui/welcome.py +++ b/manuskript/ui/welcome.py @@ -15,8 +15,7 @@ from manuskript import settings from manuskript.enums import Outline from manuskript.functions import mainWindow, iconFromColor, appPath from manuskript.models.characterModel import characterModel -from manuskript.models.outlineModel import outlineItem -from manuskript.models.outlineModel import outlineModel +from manuskript.models import outlineItem, outlineModel from manuskript.models.plotModel import plotModel from manuskript.models.worldModel import worldModel from manuskript.ui.welcome_ui import Ui_welcome diff --git a/sample-projects/book-of-acts/revisions.xml b/sample-projects/book-of-acts/revisions.xml index 84fab66..be9aed4 100644 --- a/sample-projects/book-of-acts/revisions.xml +++ b/sample-projects/book-of-acts/revisions.xml @@ -1,5 +1,5 @@ - + diff --git a/sample-projects/book-of-acts/settings.txt b/sample-projects/book-of-acts/settings.txt index e98661f..cbe5dc0 100644 --- a/sample-projects/book-of-acts/settings.txt +++ b/sample-projects/book-of-acts/settings.txt @@ -11,6 +11,7 @@ "corkStyle": "new", "defaultTextType": "md", "dict": "en_US", + "dontShowDeleteWarning": false, "folderView": "cork", "frequencyAnalyzer": { "phraseMax": 5, @@ -48,11 +49,16 @@ "spellcheck": false, "textEditor": { "background": "#fff", + "backgroundTransparent": false, + "cursorNotBlinking": false, "cursorWidth": 1, "font": "DejaVu Sans,10,-1,5,50,0,0,0,0,0", "fontColor": "#000", "indent": true, "lineSpacing": 100, + "marginsLR": 0, + "marginsTB": 0, + "maxWidth": 0, "misspelled": "#F00", "spacingAbove": 5, "spacingBelow": 5, @@ -78,7 +84,8 @@ "Icon": "Nothing", "InfoFolder": "Summary", "InfoText": "Nothing", - "Text": "Compile" + "Text": "Compile", + "iconSize": 24 } } } \ No newline at end of file