manuskript/manuskript/load_save/version_1.py

575 lines
17 KiB
Python
Raw Normal View History

#!/usr/bin/env python
# --!-- coding: utf8 --!--
# Version 1 of file saving format.
# Aims at providing a plain-text way of saving a project
# (except for some elements), allowing collaborative work
# versioning and third-partty editing.
2016-03-10 03:54:01 +13:00
import os
import shutil
import string
import zipfile
from PyQt5.QtCore import Qt, QModelIndex
2016-03-05 12:35:14 +13:00
from PyQt5.QtGui import QColor
from manuskript import settings
2016-03-08 21:21:44 +13:00
from manuskript.enums import Character, World, Plot, PlotStep, Outline
2016-03-05 12:35:14 +13:00
from manuskript.functions import mainWindow, iconColor
from lxml import etree as ET
try:
import zlib # Used with zipfile for compression
compression = zipfile.ZIP_DEFLATED
except:
compression = zipfile.ZIP_STORED
2016-03-05 12:35:14 +13:00
cache = {}
2016-03-06 00:55:56 +13:00
def formatMetaData(name, value, tabLength=10):
# Multiline formatting
if len(value.split("\n")) > 1:
value = "\n".join([" " * (tabLength + 1) + l for l in value.split("\n")])[tabLength + 1:]
# Avoid empty description (don't know how much MMD loves that)
if name == "":
name = "None"
# Escapes ":" in name
name = name.replace(":", "_.._")
2016-03-06 00:55:56 +13:00
return "{name}:{spaces}{value}\n".format(
name=name,
spaces=" " * (tabLength - len(name)),
value=value
)
def slugify(name):
"""
A basic slug function, that escapes all spaces to "_" and all non letters/digits to "-".
@param name: name to slugify (str)
@return: str
"""
valid = string.ascii_letters + string.digits
newName = ""
for c in name:
if c in valid:
newName += c
elif c in string.whitespace:
newName += "_"
else:
newName += "-"
return newName
2016-03-07 04:27:03 +13:00
def log(*args):
print(" ".join(str(a) for a in args))
def saveProject(zip=None):
"""
Saves the project. If zip is False, the project is saved as a multitude of plain-text files for the most parts
and some XML or zip? for settings and stuff.
If zip is True, everything is saved as a single zipped file. Easier to carry around, but does not allow
collaborative work, versionning, or third-party editing.
@param zip: if True, saves as a single file. If False, saves as plain-text. If None, tries to determine based on
settings.
@return: Nothing
"""
if zip is None:
2016-03-06 00:55:56 +13:00
zip = False
# Fixme
log("\n\nSaving to:", "zip" if zip else "folder")
# List of files to be written
files = []
# List of files to be removed
removes = []
# List of files to be moved
moves = []
mw = mainWindow()
2016-03-07 04:27:03 +13:00
if zip:
# File format version
files.append(("VERSION", "1"))
2016-03-05 12:35:14 +13:00
# General infos (book and author)
# Saved in plain text, in infos.txt
path = "infos.txt"
content = ""
for name, col in [
2016-03-10 03:54:01 +13:00
("Title", 0),
("Subtitle", 1),
("Serie", 2),
("Volume", 3),
("Genre", 4),
("License", 5),
("Author", 6),
("Email", 7),
]:
2016-03-05 12:35:14 +13:00
val = mw.mdlFlatData.item(0, col).text().strip()
if val:
content += "{name}:{spaces}{value}\n".format(
name=name,
spaces=" " * (15 - len(name)),
value=val
)
files.append((path, content))
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Summary
# In plain text, in summary.txt
path = "summary.txt"
content = ""
for name, col in [
2016-03-10 03:54:01 +13:00
("Situation", 0),
("Sentence", 1),
("Paragraph", 2),
("Page", 3),
("Full", 4),
]:
2016-03-05 12:35:14 +13:00
val = mw.mdlFlatData.item(1, col).text().strip()
if val:
content += formatMetaData(name, val, 12)
2016-03-05 12:35:14 +13:00
files.append((path, content))
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Label & Status
# In plain text
for mdl, path in [
(mw.mdlStatus, "status.txt"),
(mw.mdlLabels, "labels.txt")
]:
content = ""
# We skip the first row, which is empty and transparent
for i in range(1, mdl.rowCount()):
color = ""
if mdl.data(mdl.index(i, 0), Qt.DecorationRole) is not None:
color = iconColor(mdl.data(mdl.index(i, 0), Qt.DecorationRole)).name(QColor.HexRgb)
color = color if color != "#ff000000" else "#00000000"
text = mdl.data(mdl.index(i, 0))
if text:
content += "{name}{color}\n".format(
name=text,
2016-03-10 03:54:01 +13:00
color="" if color == "" else ":" + " " * (20 - len(text)) + color
2016-03-05 12:35:14 +13:00
)
files.append((path, content))
####################################################################################################################
# Characters
2016-03-05 12:35:14 +13:00
# In a character folder
2016-03-08 21:21:44 +13:00
path = os.path.join("characters", "{name}.txt")
2016-03-06 00:55:56 +13:00
_map = [
(Character.name, "Name"),
(Character.ID, "ID"),
(Character.importance, "Importance"),
(Character.motivation, "Motivation"),
(Character.goal, "Goal"),
(Character.conflict, "Conflict"),
(Character.summarySentence, "Phrase Summary"),
(Character.summaryPara, "Paragraph Summary"),
(Character.summaryFull, "Full Summary"),
(Character.notes, "Notes"),
]
mdl = mw.mdlCharacter
# Review characters
2016-03-06 00:55:56 +13:00
for c in mdl.characters:
# Generates file's content
2016-03-06 00:55:56 +13:00
content = ""
for m, name in _map:
val = mdl.data(c.index(m.value)).strip()
if val:
content += formatMetaData(name, val, 20)
2016-03-06 00:55:56 +13:00
for info in c.infos:
content += formatMetaData(info.description, info.value, 20)
2016-03-06 00:55:56 +13:00
# generate file's path
cpath = path.format(name="{ID}-{slugName}".format(
2016-03-06 00:55:56 +13:00
ID=c.ID(),
slugName=slugify(c.name())
))
2016-03-07 04:27:03 +13:00
# Has the character been renamed?
if c.lastPath and cpath != c.lastPath:
moves.append((c.lastPath, cpath))
# Update character's path
c.lastPath = cpath
2016-03-05 12:35:14 +13:00
files.append((cpath, content))
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Texts
# In an outline folder
2016-03-08 21:21:44 +13:00
mdl = mw.mdlOutline
2016-03-10 03:48:59 +13:00
# Go through the tree
f, m, r = exportOutlineItem(mdl.rootItem)
files += f
moves += m
removes += r
2016-03-05 12:35:14 +13:00
####################################################################################################################
# World
2016-03-05 12:35:14 +13:00
# Either in an XML file, or in lots of plain texts?
# More probably text, since there might be writing done in third-party.
path = "world.opml"
mdl = mw.mdlWorld
root = ET.Element("opml")
root.attrib["version"] = "1.0"
body = ET.SubElement(root, "body")
addWorldItem(body, mdl)
content = ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True)
files.append((path, content))
2016-03-05 12:35:14 +13:00
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Plots (mw.mdlPlots)
# Either in XML or lots of plain texts?
# More probably XML since there is not really a lot if writing to do (third-party)
2016-03-07 04:27:03 +13:00
path = "plots.xml"
mdl = mw.mdlPlots
2016-03-07 04:27:03 +13:00
root = ET.Element("root")
addPlotItem(root, mdl)
content = ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True)
files.append((path, content))
2016-03-05 12:35:14 +13:00
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Settings
# Saved in readable text (json) for easier versionning. But they mustn't be shared, it seems.
# Maybe include them only if zipped?
# Well, for now, we keep them here...
2016-03-05 12:35:14 +13:00
files.append(("settings.txt", settings.save(protocol=0)))
project = mw.currentProject
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Save to zip
2016-03-05 12:35:14 +13:00
if zip:
project = os.path.join(
os.path.dirname(project),
"_" + os.path.basename(project)
)
zf = zipfile.ZipFile(project, mode="w")
for filename, content in files:
zf.writestr(filename, content, compress_type=compression)
zf.close()
####################################################################################################################
2016-03-05 12:35:14 +13:00
# Save to plain text
2016-03-05 12:35:14 +13:00
else:
2016-03-10 03:48:59 +13:00
global cache
# Project path
2016-03-05 12:35:14 +13:00
dir = os.path.dirname(project)
# Folder containing file: name of the project file (without .msk extension)
2016-03-05 12:35:14 +13:00
folder = os.path.splitext(os.path.basename(project))[0]
# Debug
log("\nSaving to folder", folder)
# Moving files that have been renamed
for old, new in moves:
# Get full path
oldPath = os.path.join(dir, folder, old)
newPath = os.path.join(dir, folder, new)
# Move the old file to the new place
2016-03-10 03:48:59 +13:00
try:
os.replace(oldPath, newPath)
log("* Renaming/moving {} to {}".format(old, new))
except FileNotFoundError:
# Maybe parent folder has been renamed
pass
# Update cache
2016-03-10 03:48:59 +13:00
cache2 = {}
for f in cache:
f2 = f.replace(old, new)
if f2 != f:
log(" * Updating cache:", f, f2)
cache2[f2] = cache[f]
cache = cache2
# Writing files
2016-03-05 12:35:14 +13:00
for path, content in files:
filename = os.path.join(dir, folder, path)
os.makedirs(os.path.dirname(filename), exist_ok=True)
2016-03-05 12:35:14 +13:00
# TODO: the first time it saves, it will overwrite everything, since it's not yet in cache.
# Or we have to cache while loading.
2016-03-10 03:54:01 +13:00
if path not in cache or cache[path] != content:
log("* Writing file", path)
2016-03-10 03:54:01 +13:00
mode = "w" + ("b" if type(content) == bytes else "")
2016-03-05 12:35:14 +13:00
with open(filename, mode) as f:
f.write(content)
cache[path] = content
else:
pass
# log(" In cache, and identical. Do nothing.")
2016-03-10 03:48:59 +13:00
# Removing phantoms
2016-03-10 03:54:01 +13:00
for path in [p for p in cache if p not in [p for p, c in files]]:
2016-03-10 03:48:59 +13:00
filename = os.path.join(dir, folder, path)
log("* Removing", path)
if os.path.isdir(filename):
shutil.rmtree(filename)
else: # elif os.path.exists(filename)
os.remove(filename)
# Clear cache
cache.pop(path, 0)
# Removing empty directories
for root, dirs, files in os.walk(os.path.join(dir, folder, "outline")):
for dir in dirs:
newDir = os.path.join(root, dir)
try:
os.removedirs(newDir)
log("* Removing empty directory:", newDir)
except:
# Directory not empty, we don't remove.
pass
2016-03-07 04:27:03 +13:00
def addWorldItem(root, mdl, parent=QModelIndex()):
"""
Lists elements in a world model and create an OPML xml file.
@param root: an Etree element
@param mdl: a worldModel
@param parent: the parent index in the world model
@return: root, to which sub element have been added
"""
# List every row (every world item)
for x in range(mdl.rowCount(parent)):
# For each row, create an outline item.
outline = ET.SubElement(root, "outline")
for y in range(mdl.columnCount(parent)):
val = mdl.data(mdl.index(x, y, parent))
if not val:
continue
for w in World:
if y == w.value:
outline.attrib[w.name] = val
if mdl.hasChildren(mdl.index(x, y, parent)):
addWorldItem(outline, mdl, mdl.index(x, y, parent))
return root
def addPlotItem(root, mdl, parent=QModelIndex()):
"""
2016-03-07 04:27:03 +13:00
Lists elements in a plot model and create an xml file.
@param root: an Etree element
@param mdl: a plotModel
@param parent: the parent index in the plot model
@return: root, to which sub element have been added
"""
# List every row (every plot item)
for x in range(mdl.rowCount(parent)):
# For each row, create an outline item.
2016-03-07 04:27:03 +13:00
outline = ET.SubElement(root, "plot")
for y in range(mdl.columnCount(parent)):
index = mdl.index(x, y, parent)
val = mdl.data(index)
if not val:
continue
for w in Plot:
if y == w.value:
outline.attrib[w.name] = val
2016-03-07 04:27:03 +13:00
# List characters as attrib
if y == Plot.characters.value:
if mdl.hasChildren(index):
characters = []
for cX in range(mdl.rowCount(index)):
for cY in range(mdl.columnCount(index)):
cIndex = mdl.index(cX, cY, index)
characters.append(mdl.data(cIndex))
outline.attrib[Plot.characters.name] = ",".join(characters)
else:
outline.attrib.pop(Plot.characters.name)
2016-03-07 04:27:03 +13:00
# List resolution steps as sub items
elif y == Plot.steps.value:
if mdl.hasChildren(index):
for cX in range(mdl.rowCount(index)):
step = ET.SubElement(outline, "step")
for cY in range(mdl.columnCount(index)):
cIndex = mdl.index(cX, cY, index)
val = mdl.data(cIndex)
2016-03-07 04:27:03 +13:00
for w in PlotStep:
if cY == w.value:
step.attrib[w.name] = val
2016-03-07 04:27:03 +13:00
outline.attrib.pop(Plot.steps.name)
return root
2016-03-07 04:27:03 +13:00
2016-03-08 21:21:44 +13:00
def exportOutlineItem(root):
"""
Takes an outline item, and returns two lists:
1. of (`filename`, `content`), representing the whole tree of files to be written, in multimarkdown.
3. of (`filename`, `filename`) listing files to be moved
2. of `filename`, representing files to be removed.
2016-03-08 21:21:44 +13:00
@param root: OutlineItem
@return: [(str, str)], [str]
2016-03-08 21:21:44 +13:00
"""
files = []
moves = []
removes = []
2016-03-10 03:54:01 +13:00
k = 0
2016-03-08 21:21:44 +13:00
for child in root.children():
2016-03-10 03:54:01 +13:00
spath = os.path.join(*outlineItemPath(child))
2016-03-10 03:48:59 +13:00
2016-03-08 21:21:44 +13:00
k += 1
# Has the item been renamed?
2016-03-10 03:48:59 +13:00
lp = child._lastPath
if lp and spath != lp:
moves.append((lp, spath))
log(child.title(), "has been renamed (", lp, "", spath, ")")
log(" → We mark for moving:", lp)
# Updates item last's path
2016-03-10 03:54:01 +13:00
child._lastPath = spath
# Generating content
2016-03-08 21:21:44 +13:00
if child.type() == "folder":
fpath = os.path.join(spath, "folder.txt")
content = outlineToMMD(child)
files.append((fpath, content))
2016-03-08 21:21:44 +13:00
elif child.type() in ["txt", "t2t"]:
content = outlineToMMD(child)
files.append((spath, content))
2016-03-08 21:21:44 +13:00
elif child.type() in ["html"]:
2016-03-10 03:48:59 +13:00
# Save as html. Not the most beautiful, but hey.
content = outlineToMMD(child)
files.append((spath, content))
2016-03-08 21:21:44 +13:00
else:
log("Unknown type")
2016-03-08 21:21:44 +13:00
f, m, r = exportOutlineItem(child)
files += f
moves += m
removes += r
2016-03-08 21:21:44 +13:00
return files, moves, removes
2016-03-08 21:21:44 +13:00
2016-03-10 03:54:01 +13:00
2016-03-08 21:21:44 +13:00
def outlineItemPath(item):
2016-03-10 03:48:59 +13:00
"""
Returns the outlineItem file path (like the path where it will be written on the disk). As a list of folder's
name. To be joined by os.path.join.
@param item: outlineItem
@return: list of folder's names
"""
2016-03-08 21:21:44 +13:00
# Root item
if not item.parent():
2016-03-10 03:48:59 +13:00
return ["outline"]
2016-03-08 21:21:44 +13:00
else:
name = "{ID}-{name}{ext}".format(
ID=item.row(),
name=slugify(item.title()),
2016-03-10 03:48:59 +13:00
ext="" if item.type() == "folder" else ".md" # ".{}".format(item.type()) # To have .txt, .t2t, .html, ...
2016-03-08 21:21:44 +13:00
)
return outlineItemPath(item.parent()) + [name]
2016-03-10 03:54:01 +13:00
2016-03-08 21:21:44 +13:00
def outlineToMMD(item):
content = ""
# We don't want to write some datas (computed)
exclude = [Outline.wordCount, Outline.goal, Outline.goalPercentage, Outline.revisions, Outline.text]
# We want to force some data even if they're empty
force = [Outline.compile]
for attrib in Outline:
2016-03-10 03:54:01 +13:00
if attrib in exclude:
continue
2016-03-08 21:21:44 +13:00
val = item.data(attrib.value)
if val or attrib in force:
content += formatMetaData(attrib.name, str(val), 15)
content += "\n\n"
content += item.data(Outline.text.value)
# Saving revisions
# TODO: saving revisions?
2016-03-08 21:21:44 +13:00
# rev = item.revisions()
# for r in rev:
# revItem = ET.Element("revision")
# revItem.set("timestamp", str(r[0]))
# revItem.set("text", r[1])
# item.append(revItem)
return content
2016-03-10 03:54:01 +13:00
def loadProject(project):
"""
Loads a project.
@param project: the filename of the project to open.
@return: an array of errors, empty if None.
"""
2016-03-05 12:35:14 +13:00
# Don't forget to cache everything that is loaded
# In order to save only what has changed.
pass