mirror of
https://github.com/olivierkes/manuskript.git
synced 2024-06-10 06:54:32 +12:00
a27a1f399a
This makes the fullscreen editor much more powerful in terms of navigating through chapters and scenes. This should make issue #234 users happy and fix #444. Top panel now has left/right arrows to navigate through the scenes. It will automatically find the next/previous text item and display it, navigating through the outline tree. There's also a "New document" icon which will create a new text entry immediately after the current one and switch to it. Navigation can also be done using Alt+Page-Up, Alt+Page-Down or Alt+Left and Alt+right shortcuts (Fixing #444). There's now also the option between Title or Path for the top panel, if Path is chosen then the full path of the scene is displayed and clicking on the scene or parent items opens a menu to quickly switch to the selected chapter/scene. Selecting a folder will automatically display the first text entry available in that folder.
508 lines
16 KiB
Python
508 lines
16 KiB
Python
#!/usr/bin/env python
|
|
# --!-- coding: utf8 --!--
|
|
|
|
import time
|
|
import locale
|
|
from PyQt5.QtCore import Qt
|
|
from PyQt5.QtGui import QFont, QIcon
|
|
from PyQt5.QtWidgets import qApp
|
|
from lxml import etree as ET
|
|
from manuskript.models.abstractItem import abstractItem
|
|
from manuskript import enums
|
|
from manuskript import functions as F
|
|
from manuskript import settings
|
|
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 formatting
|
|
pass
|
|
|
|
|
|
class outlineItem(abstractItem):
|
|
|
|
enum = enums.Outline
|
|
|
|
# Used for XML export
|
|
name = "outlineItem"
|
|
|
|
def __init__(self, model=None, title="", _type="folder", xml=None, parent=None, ID=None):
|
|
abstractItem.__init__(self, model, title, _type, xml, parent, ID)
|
|
|
|
self.defaultTextType = None
|
|
if not self._data.get(self.enum.compile):
|
|
self._data[self.enum.compile] = 2
|
|
|
|
#######################################################################
|
|
# Properties
|
|
#######################################################################
|
|
|
|
def isFolder(self):
|
|
return self._data[self.enum.type] == "folder"
|
|
|
|
def isText(self):
|
|
return self._data[self.enum.type] == "md"
|
|
|
|
def isMD(self):
|
|
return self._data[self.enum.type] == "md"
|
|
|
|
def isMMD(self):
|
|
return self._data[self.enum.type] == "md"
|
|
|
|
def text(self):
|
|
return self.data(self.enum.text)
|
|
|
|
def compile(self):
|
|
if self._data.get(self.enum.compile, 1) in ["0", 0]:
|
|
return False
|
|
elif self.parent():
|
|
return self.parent().compile()
|
|
else:
|
|
return True # rootItem always compile
|
|
|
|
def POV(self):
|
|
return self.data(self.enum.POV)
|
|
|
|
def status(self):
|
|
return self.data(self.enum.status)
|
|
|
|
def label(self):
|
|
return self.data(self.enum.label)
|
|
|
|
def customIcon(self):
|
|
return self.data(self.enum.customIcon)
|
|
|
|
def setCustomIcon(self, customIcon):
|
|
self.setData(self.enum.customIcon, customIcon)
|
|
|
|
def wordCount(self):
|
|
return self._data.get(self.enum.wordCount, 0)
|
|
|
|
def __str__(self):
|
|
return "{id}: {folder}{title}{children}".format(
|
|
id=self.ID(),
|
|
folder="*" if self.isFolder() else "",
|
|
title=self.data(self.enum.title),
|
|
children="" if self.isText() else "({})".format(self.childCount())
|
|
)
|
|
|
|
__repr__ = __str__
|
|
|
|
#######################################################################
|
|
# Data
|
|
#######################################################################
|
|
|
|
def data(self, column, role=Qt.DisplayRole):
|
|
|
|
data = abstractItem.data(self, column, role)
|
|
E = self.enum
|
|
|
|
if role == Qt.DisplayRole or role == Qt.EditRole:
|
|
if data == "" and column == E.revisions:
|
|
return []
|
|
|
|
else:
|
|
return data
|
|
|
|
elif role == Qt.DecorationRole and column == E.title:
|
|
if self.customIcon():
|
|
return QIcon.fromTheme(self.data(E.customIcon))
|
|
if self.isFolder():
|
|
return QIcon.fromTheme("folder")
|
|
elif self.isText():
|
|
return QIcon.fromTheme("text-x-generic")
|
|
|
|
elif role == Qt.CheckStateRole and column == E.compile:
|
|
return Qt.Checked if self.compile() else Qt.Unchecked
|
|
|
|
elif role == Qt.FontRole:
|
|
f = QFont()
|
|
if column == E.wordCount and self.isFolder():
|
|
f.setItalic(True)
|
|
elif column == E.goal and self.isFolder() and not self.data(E.setGoal):
|
|
f.setItalic(True)
|
|
if self.isFolder():
|
|
f.setBold(True)
|
|
return f
|
|
|
|
def setData(self, column, data, role=Qt.DisplayRole):
|
|
|
|
E = self.enum
|
|
|
|
if column == E.text and self.isFolder():
|
|
# Folder have no text
|
|
return
|
|
|
|
if column == E.goal:
|
|
self._data[E.setGoal] = F.toInt(data) if F.toInt(data) > 0 else ""
|
|
|
|
# Checking if we will have to recount words
|
|
updateWordCount = False
|
|
if column in [E.wordCount, E.goal, E.setGoal]:
|
|
updateWordCount = not column in self._data or self._data[column] != data
|
|
|
|
# Stuff to do before
|
|
if column == E.text:
|
|
self.addRevision()
|
|
|
|
# Calling base class implementation
|
|
abstractItem.setData(self, column, data, role)
|
|
|
|
# Stuff to do afterwards
|
|
if column == E.text:
|
|
wc = F.wordCount(data)
|
|
self.setData(E.wordCount, wc)
|
|
|
|
if column == E.compile:
|
|
# Title changes when compile changes
|
|
self.emitDataChanged(cols=[E.title, E.compile],
|
|
recursive=True)
|
|
|
|
if column == E.customIcon:
|
|
# If custom icon changed, we tell views to update title (so that
|
|
# icons will be updated as well)
|
|
self.emitDataChanged(cols=[E.title])
|
|
|
|
if updateWordCount:
|
|
self.updateWordCount()
|
|
|
|
#######################################################################
|
|
# Wordcount
|
|
#######################################################################
|
|
|
|
def insertChild(self, row, child):
|
|
abstractItem.insertChild(self, row, child)
|
|
self.updateWordCount()
|
|
|
|
def removeChild(self, row):
|
|
r = abstractItem.removeChild(self, row)
|
|
# Might be causing segfault when updateWordCount emits dataChanged
|
|
self.updateWordCount(emit=False)
|
|
return r
|
|
|
|
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 = F.toInt(self.data(self.enum.setGoal))
|
|
goal = F.toInt(self.data(self.enum.goal))
|
|
|
|
if goal != setGoal:
|
|
self._data[self.enum.goal] = setGoal
|
|
if setGoal:
|
|
wc = F.toInt(self.data(self.enum.wordCount))
|
|
self.setData(self.enum.goalPercentage, wc / float(setGoal))
|
|
|
|
else:
|
|
wc = 0
|
|
for c in self.children():
|
|
wc += F.toInt(c.data(self.enum.wordCount))
|
|
self._data[self.enum.wordCount] = wc
|
|
|
|
setGoal = F.toInt(self.data(self.enum.setGoal))
|
|
goal = F.toInt(self.data(self.enum.goal))
|
|
|
|
if setGoal:
|
|
if goal != setGoal:
|
|
self._data[self.enum.goal] = setGoal
|
|
goal = setGoal
|
|
else:
|
|
goal = 0
|
|
for c in self.children():
|
|
goal += F.toInt(c.data(self.enum.goal))
|
|
self._data[self.enum.goal] = goal
|
|
|
|
if goal:
|
|
self.setData(self.enum.goalPercentage, wc / float(goal))
|
|
else:
|
|
self.setData(self.enum.goalPercentage, "")
|
|
|
|
if emit:
|
|
self.emitDataChanged([self.enum.goal, self.enum.setGoal,
|
|
self.enum.wordCount, self.enum.goalPercentage])
|
|
|
|
if self.parent():
|
|
self.parent().updateWordCount(emit)
|
|
|
|
def stats(self):
|
|
wc = self.data(enums.Outline.wordCount)
|
|
goal = self.data(enums.Outline.goal)
|
|
progress = self.data(enums.Outline.goalPercentage)
|
|
if not wc:
|
|
wc = 0
|
|
if goal:
|
|
return qApp.translate("outlineItem", "{} words / {} ({})").format(
|
|
locale.format_string("%d", wc, grouping=True),
|
|
locale.format_string("%d", goal, grouping=True),
|
|
"{}%".format(str(int(progress * 100))))
|
|
else:
|
|
return qApp.translate("outlineItem", "{} words").format(
|
|
locale.format_string("%d", wc, grouping=True))
|
|
|
|
#######################################################################
|
|
# Tools: split and merge
|
|
#######################################################################
|
|
|
|
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(self.enum.text, txt[0])
|
|
|
|
k = 1
|
|
for subTxt in txt[1:]:
|
|
# Create a copy
|
|
item = self.copy()
|
|
|
|
# Change title adding _k
|
|
item.setData(self.enum.title,
|
|
"{}_{}".format(item.title(), k+1))
|
|
|
|
# Set text
|
|
item.setData(self.enum.text, 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(self.enum.text, 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(self.enum.title, title)
|
|
|
|
# Set text
|
|
item.setData(self.enum.text, 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(self.enum.text, sep.join(text))
|
|
|
|
# Removes other items
|
|
self._model.removeIndexes([i.index() for i in items])
|
|
|
|
#######################################################################
|
|
# Search
|
|
#######################################################################
|
|
|
|
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=F.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=F.mainWindow(),
|
|
caseSensitive=False):
|
|
lst = []
|
|
text = text.lower() if not caseSensitive else text
|
|
for c in columns:
|
|
|
|
if c == self.enum.POV 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 == self.enum.status:
|
|
searchIn = mainWindow.mdlStatus.item(F.toInt(self.status()), 0).text()
|
|
|
|
elif c == self.enum.label:
|
|
searchIn = mainWindow.mdlLabels.item(F.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(self.enum.revisions)
|
|
|
|
def appendRevision(self, ts, text):
|
|
if not self.enum.revisions in self._data:
|
|
self._data[self.enum.revisions] = []
|
|
|
|
self._data[self.enum.revisions].append((
|
|
int(ts),
|
|
text))
|
|
|
|
def addRevision(self):
|
|
if not settings.revisions["keep"]:
|
|
return
|
|
|
|
if not self.enum.text in self._data:
|
|
return
|
|
|
|
self.appendRevision(
|
|
time.time(),
|
|
self.text())
|
|
|
|
if settings.revisions["smartremove"]:
|
|
self.cleanRevisions()
|
|
|
|
self.emitDataChanged([self.enum.revisions])
|
|
|
|
def deleteRevision(self, ts):
|
|
self._data[self.enum.revisions] = [r for r in self._data[self.enum.revisions] if r[0] != ts]
|
|
self.emitDataChanged([self.enum.revisions])
|
|
|
|
def clearAllRevisions(self):
|
|
self._data[self.enum.revisions] = []
|
|
self.emitDataChanged([self.enum.revisions])
|
|
|
|
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[self.enum.revisions] = rev2
|
|
self.emitDataChanged([self.enum.revisions])
|
|
|
|
#######################################################################
|
|
# XML
|
|
#######################################################################
|
|
|
|
# We don't want to write some datas (computed)
|
|
XMLExclude = [enums.Outline.wordCount,
|
|
enums.Outline.goal,
|
|
enums.Outline.goalPercentage,
|
|
enums.Outline.revisions]
|
|
# We want to force some data even if they're empty
|
|
XMLForce = [enums.Outline.compile]
|
|
|
|
def toXMLProcessItem(self, item):
|
|
|
|
# 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)
|
|
|
|
return item
|
|
|
|
|
|
def setFromXMLProcessMore(self, root):
|
|
|
|
# If loading from an old file format, convert to md and
|
|
# remove html markup
|
|
if self.type() in ["txt", "t2t"]:
|
|
self.setData(Outline.type, "md")
|
|
|
|
elif self.type() == "html":
|
|
self.setData(Outline.type, "md")
|
|
self.setData(Outline.text, HTML2PlainText(self.data(Outline.text)))
|
|
self.setData(Outline.notes, HTML2PlainText(self.data(Outline.notes)))
|
|
|
|
# Revisions
|
|
for child in root:
|
|
if child.tag == "revision":
|
|
self.appendRevision(child.attrib["timestamp"], child.attrib["text"])
|