#!/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. import os import re import shutil import string import zipfile from collections import OrderedDict from PyQt5.QtCore import Qt, QModelIndex from PyQt5.QtGui import QColor, QStandardItem from manuskript import settings from manuskript.enums import Character, World, Plot, PlotStep, Outline from manuskript.functions import mainWindow, iconColor, iconFromColorString from lxml import etree as ET from manuskript.load_save.version_0 import loadFilesFromZip from manuskript.models.characterModel import CharacterInfo try: import zlib # Used with zipfile for compression compression = zipfile.ZIP_DEFLATED except: compression = zipfile.ZIP_STORED cache = {} characterMap = OrderedDict([ (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"), ]) # characterMap = { # 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", # } 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(":", "_.._") 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 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: 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() if zip: # File format version files.append(("VERSION", "1")) # General infos (book and author) # Saved in plain text, in infos.txt path = "infos.txt" content = "" for name, col in [ ("Title", 0), ("Subtitle", 1), ("Serie", 2), ("Volume", 3), ("Genre", 4), ("License", 5), ("Author", 6), ("Email", 7), ]: 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)) #################################################################################################################### # Summary # In plain text, in summary.txt path = "summary.txt" content = "" for name, col in [ ("Situation", 0), ("Sentence", 1), ("Paragraph", 2), ("Page", 3), ("Full", 4), ]: val = mw.mdlFlatData.item(1, col).text().strip() if val: content += formatMetaData(name, val, 12) files.append((path, content)) #################################################################################################################### # 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, color="" if color == "" else ":" + " " * (20 - len(text)) + color ) files.append((path, content)) #################################################################################################################### # Characters # In a character folder path = os.path.join("characters", "{name}.txt") mdl = mw.mdlCharacter # Review characters for c in mdl.characters: # Generates file's content content = "" for m in characterMap: val = mdl.data(c.index(m.value)).strip() if val: content += formatMetaData(characterMap[m], val, 20) # Character's color: content += formatMetaData("Color", c.color().name(QColor.HexRgb), 20) # Character's infos for info in c.infos: content += formatMetaData(info.description, info.value, 20) # generate file's path cpath = path.format(name="{ID}-{slugName}".format( ID=c.ID(), slugName=slugify(c.name()) )) # Has the character been renamed? if c.lastPath and cpath != c.lastPath: moves.append((c.lastPath, cpath)) # Update character's path c.lastPath = cpath files.append((cpath, content)) #################################################################################################################### # Texts # In an outline folder mdl = mw.mdlOutline # Go through the tree f, m, r = exportOutlineItem(mdl.rootItem) files += f moves += m removes += r # Writes revisions (if asked for) if settings.revisions["keep"]: files.append(("revisions.xml", mdl.saveToXML())) #################################################################################################################### # World # 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)) #################################################################################################################### # 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) path = "plots.xml" mdl = mw.mdlPlots root = ET.Element("root") addPlotItem(root, mdl) content = ET.tostring(root, encoding="UTF-8", xml_declaration=True, pretty_print=True) files.append((path, content)) #################################################################################################################### # 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... files.append(("settings.txt", settings.save(protocol=0))) project = mw.currentProject #################################################################################################################### # Save to zip 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() #################################################################################################################### # Save to plain text else: global cache # Project path dir = os.path.dirname(project) # Folder containing file: name of the project file (without .msk extension) 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 try: os.replace(oldPath, newPath) log("* Renaming/moving {} to {}".format(old, new)) except FileNotFoundError: # Maybe parent folder has been renamed pass # Update cache 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 for path, content in files: filename = os.path.join(dir, folder, path) os.makedirs(os.path.dirname(filename), exist_ok=True) # Check if content is in cache, and write if necessary if path not in cache or cache[path] != content: log("* Writing file", path) mode = "w" + ("b" if type(content) == bytes else "") with open(filename, mode) as f: f.write(content) cache[path] = content # Removing phantoms for path in [p for p in cache if p not in [p for p, c in files]]: 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 # Write the project file's content with open(project, "w") as f: f.write("1") # Format number 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()): """ 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. 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 # 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) # 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) for w in PlotStep: if cY == w.value: step.attrib[w.name] = val outline.attrib.pop(Plot.steps.name) return root 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. @param root: OutlineItem @return: [(str, str)], [str] """ files = [] moves = [] removes = [] k = 0 for child in root.children(): spath = os.path.join(*outlineItemPath(child)) k += 1 # Has the item been renamed? 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 child._lastPath = spath # Generating content if child.type() == "folder": fpath = os.path.join(spath, "folder.txt") content = outlineToMMD(child) files.append((fpath, content)) elif child.type() in ["txt", "t2t"]: content = outlineToMMD(child) files.append((spath, content)) elif child.type() in ["html"]: # Save as html. Not the most beautiful, but hey. content = outlineToMMD(child) files.append((spath, content)) else: log("Unknown type") f, m, r = exportOutlineItem(child) files += f moves += m removes += r return files, moves, removes def outlineItemPath(item): """ 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 """ # Root item if not item.parent(): return ["outline"] else: name = "{ID}-{name}{ext}".format( ID=item.row(), name=slugify(item.title()), ext="" if item.type() == "folder" else ".md" # ".{}".format(item.type()) # To have .txt, .t2t, .html, ... ) return outlineItemPath(item.parent()) + [name] 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: if attrib in exclude: continue 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? # 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 ######################################################################################################################## # LOAD ######################################################################################################################## def loadProject(project, zip=None): """ Loads a project. @param project: the filename of the project to open. @param zip: whether the project is a zipped or not. @return: an array of errors, empty if None. """ # FIXME: Don't forget to cache everything that is loaded # In order to save only what has changed. mw = mainWindow() errors = [] #################################################################################################################### # Read and store everything in a dict log("\nLoading {} ({})".format(project, "ZIP" if zip else "not zip")) if zip: files = loadFilesFromZip(project) # Decode files for f in files: if f[-4:] not in [".xml", "opml"]: files[f] = files[f].decode("utf-8") else: # Project path dir = os.path.dirname(project) # Folder containing file: name of the project file (without .msk extension) folder = os.path.splitext(os.path.basename(project))[0] # The full path towards the folder containing files path = os.path.join(dir, folder, "") files = {} for dirpath, dirnames, filenames in os.walk(path): p = dirpath.replace(path, "") for f in filenames: mode = "r" + ("b" if f[-4:] in [".xml", "opml"] else "") with open(os.path.join(dirpath, f), mode) as fo: files[os.path.join(p, f)] = fo.read() #################################################################################################################### # Settings if "settings.txt" in files: settings.load(files["settings.txt"], fromString=True, protocol=0) else: errors.append("settings.txt") #################################################################################################################### # Labels mdl = mw.mdlLabels mdl.appendRow(QStandardItem("")) # Empty = No labels if "labels.txt" in files: log("\nReading labels:") for s in files["labels.txt"].split("\n"): if not s: continue m = re.search(r"^(.*?):\s*(.*)$", s) txt = m.group(1) col = m.group(2) log("* Add status: {} ({})".format(txt, col)) icon = iconFromColorString(col) mdl.appendRow(QStandardItem(icon, txt)) else: errors.append("labels.txt") #################################################################################################################### # Status mdl = mw.mdlStatus mdl.appendRow(QStandardItem("")) # Empty = No status if "status.txt" in files: log("\nReading Status:") for s in files["status.txt"].split("\n"): if not s: continue log("* Add status:", s) mdl.appendRow(QStandardItem(s)) else: errors.append("status.txt") #################################################################################################################### # Infos mdl = mw.mdlFlatData if "infos.txt" in files: md, body = parseMMDFile(files["infos.txt"], asDict=True) row = [] for name in ["Title", "Subtitle", "Serie", "Volume", "Genre", "License", "Author", "Email"]: row.append(QStandardItem(md.get(name, ""))) mdl.appendRow(row) else: errors.append("infos.txt") #################################################################################################################### # Summary mdl = mw.mdlFlatData if "summary.txt" in files: md, body = parseMMDFile(files["summary.txt"], asDict=True) row = [] for name in ["Situation", "Sentence", "Paragraph", "Page", "Full"]: row.append(QStandardItem(md.get(name, ""))) mdl.appendRow(row) else: errors.append("summary.txt") #################################################################################################################### # Plots mdl = mw.mdlPlots if "plots.xml" in files: log("\nReading plots:") # xml = bytearray(files["plots.xml"], "utf-8") root = ET.fromstring(files["plots.xml"]) for plot in root: # Create row row = getStandardItemRowFromXMLEnum(plot, Plot) # Log log("* Add plot: ", row[0].text()) # Characters if row[Plot.characters.value].text(): IDs = row[Plot.characters.value].text().split(",") item = QStandardItem() for ID in IDs: item.appendRow(QStandardItem(ID.strip())) row[Plot.characters.value] = item # Subplots for step in plot: row[Plot.steps.value].appendRow( getStandardItemRowFromXMLEnum(step, PlotStep) ) # Add row to the model mdl.appendRow(row) else: errors.append("plots.xml") #################################################################################################################### # World mdl = mw.mdlWorld if "world.opml" in files: log("\nReading World:") # xml = bytearray(files["plots.xml"], "utf-8") root = ET.fromstring(files["world.opml"]) body = root.find("body") for outline in body: row = getOutlineItem(outline, World) mdl.appendRow(row) else: errors.append("world.opml") #################################################################################################################### # Characters mdl = mw.mdlCharacter log("\nReading Characters:") for f in [f for f in files if "characters" in f]: md, body = parseMMDFile(files[f]) c = mdl.addCharacter() color = False for desc, val in md: # Base infos if desc in characterMap.values(): key = [key for key, value in characterMap.items() if value == desc][0] index = c.index(key.value) mdl.setData(index, val) # Character color elif desc == "Color" and not color: c.setColor(QColor(val)) # We remember the first time we found "Color": it is the icon color. # If "Color" comes a second time, it is a Character's info. color = True # Character's infos else: c.infos.append(CharacterInfo(c, desc, val)) log("* Adds {} ({})".format(c.name(), c.ID())) # if "perso.xml" in files: # loadStandardItemModelXMLForCharacters(mw.mdlCharacter, files["perso.xml"]) # else: # errors.append("perso.xml") # # # if "outline.xml" in files: # mw.mdlOutline.loadFromXML(files["outline.xml"], fromString=True) # else: # errors.append("outline.xml") # # return errors def getOutlineItem(item, enum): row = getStandardItemRowFromXMLEnum(item, enum) log("* Add worldItem:", row[0].text()) for child in item: sub = getOutlineItem(child, enum) row[0].appendRow(sub) return row def getStandardItemRowFromXMLEnum(item, enum): """ Reads and etree item and creates a row of QStandardItems by cross-referencing an enum. Returns a list of QStandardItems that can be added to a QStandardItemModel by appendRow. @param item: the etree item @param enum: the enum @return: list of QStandardItems """ row = [] for i in range(len(enum)): row.append(QStandardItem("")) for name in item.attrib: if name in enum.__members__: row[enum[name].value] = QStandardItem(item.attrib[name]) return row def parseMMDFile(text, asDict=False): """ Takes the content of a MultiMarkDown file (str) and returns: 1. A list containing metadatas: (description, value) if asDict is False. If asDict is True, returns metadatas as an OrderedDict. Be aware that if multiple metadatas have the same description (which is stupid, but hey), they will be lost except the last one. 2. The body of the file @param text: the content of the file @return: (list, str) or (OrderedDict, str) """ md = [] mdd = OrderedDict() body = [] descr = "" val = "" inBody = False for s in text.split("\n"): if not inBody: m = re.match(r"^(.*?):\s*(.*)$", s) if m: # Commit last metadata if descr: if descr == "None": descr = "" md.append((descr, val)) mdd[descr] = val descr = "" val = "" # Store new values descr = m.group(1) val = m.group(2) elif s[:4] == " ": val += "\n" + s.strip() elif s == "": # End of metadatas inBody = True # Commit last metadata if descr: if descr == "None": descr = "" md.append((descr, val)) mdd[descr] = val else: body.append[s] # We remove the second empty line (since we save with two empty lines) if body and body[0] == "": body = body[1:] body = "\n".join(body) if not asDict: return md, body else: return mdd, body