Source: Storage.js

/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/** @module */

import {SVGDocumentWrapper} from "./svg/SVGDocumentWrapper";
import {backendList} from "./backend/AbstractBackend";
import nunjucks from "nunjucks";
import Jed from "jed";
import {upgradeFromSVG, upgradeFromStorable} from "./upgrade";
import path from "path";

/** Replace the extension in a file name.
 *
 * @param {string} fileName - The name of a file.
 * @param {string} ext - The new extension.
 * @returns {string} - A file name with the new extension.
 */
function replaceFileExtWith(fileName, ext) {
    return fileName.replace(/\.[^/.]+$/, ext);
}

/** File read/write manager. */
export class Storage {
    /** Initialize a storage manager for a presentation.
     *
     * @param {module:Controller.Controller} controller - The controller that manages the current editor.
     * @param {module:model/Presentation.Presentation} presentation - The Sozi presentation opened in the editor.
     * @param {module:model/Selection.Selection} selection - The object that represents the selection in the timeline.
     */
    constructor(controller, presentation, selection) {
        /** The controller that manages the current editor.
         *
         * @type {module:Controller.Controller} */
        this.controller = controller;

        /** The current SVG document.
         *
         * @default
         * @type {module:svg/SVGDocumentWrapper.SVGDocumentWrapper} */
        this.document = null;

        /** The Sozi presentation opened in the editor.
         *
         * @type {module:model/Presentation.Presentation} */
        this.presentation = presentation;

        /** The object that represents the selection in the timeline.
         *
         * @type {module:model/Selection.Selection} */
        this.selection = selection;

        /** The current execution platform backend.
         *
         * @type {module:backend/AbstractBackend.AbstractBackend} */
        this.backend = backendList[0];

        /** The descriptor of the current SVG document file.
         *
         * @default
         * @type {any} */
        this.svgFileDescriptor  = null;

        /** The descriptor of the presentation HTML file.
         *
         * @default
         * @type {any} */
        this.htmlFileDescriptor = null;

        /** The descriptor of the presentation JSON file.
         *
         * @default
         * @type {any} */
        this.jsonFileDescriptor = null;

        /** Do we need to update the presentation JSON file?
         *
         * This property is true when the {@linkcode module:Controller.presentationChange|presentationChange}
         * or the {@linkcode module:Controller.editorStateChange|editorStateChange} event is detected.
         *
         * @default
         * @type {boolean} */
        this.jsonNeedsSaving = false;

        /** Do we need to update the presentation HTML file?
         *
         * This property is true when the {@linkcode module:Controller.presentationChange|presentationChange} event is detected.
         *
         * @default
         * @type {boolean} */
        this.htmlNeedsSaving = false;

        // Adjust the template path depending on the target platform.
        // In the web browser, __dirname is set to "/js". The leading "/" will result
        // in an incorrect URL if the app is not hosted at the root of its domain.
        const templatePath = __dirname === "/js" ? "templates" : path.join(__dirname, "..", "templates");

        nunjucks.configure(templatePath, {
            watch: false,
            autoescape: false
        });

        controller.on("presentationChange", () => {
            this.jsonNeedsSaving = this.htmlNeedsSaving = true;
        });

        controller.on("editorStateChange",  () => {
            this.jsonNeedsSaving = true;
        });

        controller.on("blur", () => {
            if (this.backend && controller.getPreference("saveMode") === "onblur") {
                this.backend.doAutosave();
            }
        });
    }

    /** Finalize the initialization of the application.
     *
     * Show a load button for each supported {@link module:backend/AbstractBackend.AbstractBackend|backend} in the preview area.
     * Create an instance of each supported backend.
     */
    activate() {
        for (let backend of backendList) {
            const listItem = document.createElement("li");
            document.querySelector("#sozi-editor-view-preview ul").appendChild(listItem);

            const backendInstance = new backend(this.controller, listItem);
        }
    }

    /** Save the presentation.
     *
     * This method delegates the operation to the current {@link module:backend/AbstractBackend.AbstractBackend|backend} instance and triggers
     *
     * @returns {Promise} - A promise that will be resolved when the operation completes.
     *
     * @see {@linkcode module:backend/AbstractBackend.AbstractBackend#doAutosave}
     */
    save() {
        return this.backend.doAutosave();
    }

    /** Reload the SVG document.
     *
     * This method is called automatically or on user demand when the SVG
     * document has changed.
     * It saves the presentation and reloads it completely with the new
     * SVG content.
     */
    async reload() {
        await this.save();
        const data = await this.backend.load(this.svgFileDescriptor);
        this.loadSVGData(data);
    }

    /** Assign an SVG file descriptor and backend.
     *
     * This method is called when opening a new SVG file.
     *
     * @param {any} fileDescriptor - A descriptor of the SVG file.
     * @param {module:backend/AbstractBackend.AbstractBackend} backend - The selected backend to manage the presentation files.
     */
    async setSVGFile(fileDescriptor, backend) {
        this.svgFileDescriptor = fileDescriptor;
        this.backend           = backend;
        const data = await this.backend.load(this.svgFileDescriptor);
        this.loadSVGData(data);
    }

    /** Load the content of an SVG document.
     *
     * This method creates an {@link module:svg/SVGDocumentWrapper.SVGDocumentWrapper| SVG document wrapper}
     * with the given data and assigns it to the current presentation.
     * Then it loads the presentation data from a JSON file in the same folder.
     *
     * @param {string} data  - The content of an SVG file, as text.
     */
    loadSVGData(data) {
        const _        = this.controller.gettext;
        const name     = this.backend.getName(this.svgFileDescriptor);
        const location = this.backend.getLocation(this.svgFileDescriptor);

        this.document = SVGDocumentWrapper.fromString(data);
        if (this.document.isValidSVG) {
            this.resolveRelativeURLs(location);
            this.presentation.setSVGDocument(this.document);
            this.openJSONFile(replaceFileExtWith(name, ".sozi.json"), location);
        }
        else {
            this.controller.error(_("Document is not valid SVG."));
        }
    }

    /** Fix the href attribute of linked images when the target URL is relative.
     *
     * In linked images, the `href` attribute can be either an absolute URL
     * or a path relative to the location of the SVG file.
     * But in the presentation editor, URLs are relative to the location of
     * the `index.html` file of the application.
     * For this reason, we modify image URLs by prefixing all relative URLs
     * with the actual location of the SVG file.
     *
     * @param {string} location - The path or URL of the folder containing the current SVG file.
     */
    resolveRelativeURLs(location) {
        const XLINK_NS = "http://www.w3.org/1999/xlink";
        const xlinkNsAttrs = Array.from(this.document.root.attributes).filter(a => a.value === XLINK_NS);
        if (!xlinkNsAttrs.length) {
            return;
        }
        const xlinkPrefix = xlinkNsAttrs[0].name.replace(/^xmlns:/, "") + ":";

        for (let img of this.document.root.getElementsByTagName("image")) {
            const href = img.getAttribute(xlinkPrefix + "href");
            if (!/^[a-z]+:|^[/#]/.test(href)) {
                img.setAttribute(xlinkPrefix + "href", `${location}/${href}`);
            }
        }
    }

    /** Open presentation data from a JSON file.
     *
     * It the file does not exist, it is created and populated with the current
     * presentation data.
     *
     * @param {string} name - The name of the JSON file to open.
     * @param {any} location - The location of the file (backend-dependent).
     */
    async openJSONFile(name, location) {
        const _ = this.controller.gettext;

        let fileDescriptor;
        try {
            // Load presentation data and editor state from JSON file.
            fileDescriptor = await this.backend.find(name, location);
            const data = await this.backend.load(fileDescriptor);
            this.loadJSONData(data);
        }
        catch (err) {
            // If no JSON file is available, attempt to extract
            // presentation data from the SVG document, assuming
            // it has been generated from Sozi 13 or earlier.
            // Then save the extracted data to a JSON file.
            upgradeFromSVG(this.presentation, this.controller);

            // If the document contains frames, it means it was imported from Sozi 13.
            if (this.presentation.frames.length) {
                this.controller.info(_("Document was imported from Sozi 13 or earlier."));
            }

            // Create a JSON file for the presentation data.
            fileDescriptor = await this.backend.create(name, location, "application/json", this.getJSONData());
        }

        if (!this.jsonFileDescriptor) {
            this.jsonFileDescriptor = fileDescriptor;
            this.backend.autosave(fileDescriptor, () => this.jsonNeedsSaving, () => this.getJSONData());
        }

        this.controller.onLoad();

        const svgName           = this.backend.getName(this.svgFileDescriptor);
        const htmlFileName      = replaceFileExtWith(svgName, ".sozi.html");
        const presenterFileName = replaceFileExtWith(svgName, "-presenter.sozi.html");
        this.createHTMLFile(htmlFileName, location);
        this.createPresenterHTMLFile(presenterFileName, location, htmlFileName);
    }

    /** Create the presentation HTML file if it does not exist.
     *
     * @param {string} name - The name of the HTML file to create.
     * @param {any} location - The location of the file (backend-dependent).
     */
    async createHTMLFile(name, location) {
        let fileDescriptor;
        try {
            fileDescriptor = await this.backend.find(name, location);
            this.backend.save(fileDescriptor, this.exportHTML());
        }
        catch (err) {
            fileDescriptor = await this.backend.create(name, location, "text/html", this.exportHTML());
        }

        if (!this.htmlFileDescriptor) {
            this.htmlFileDescriptor = fileDescriptor;
            this.backend.autosave(fileDescriptor, () => this.htmlNeedsSaving, () => this.exportHTML());
        }
    }

    /** Create the presenter console HTML file if it does not exist.
     *
     * @param {string} name - The name of the HTML file to create.
     * @param {any} location - The location of the file (backend-dependent).
     * @param {string} htmlFileName - The name of the presentation HTML file.
     */
    async createPresenterHTMLFile(name, location, htmlFileName) {
        try {
            const fileDescriptor = await this.backend.find(name, location);
            this.backend.save(fileDescriptor, this.exportPresenterHTML(htmlFileName));
        }
        catch (err) {
            this.backend.create(name, location, "text/html", this.exportPresenterHTML(htmlFileName));
        }
    }

    /**  Load the presentation data and set the initial state of the editor.
     *
     * @param {object} data - An object containing presentation data and the editor state, as loaded from a JSON file.
     */
    loadJSONData(data) {
        const storable = JSON.parse(data);
        upgradeFromStorable(storable);
        this.presentation.fromStorable(storable);
        this.controller.fromStorable(storable);
        this.selection.fromStorable(storable);
    }

    /** Finalize a save operation.
     *
     * This method is called by the current backend when a save operation has
     * completed.
     *
     * @param {any} fileDescriptor - A descriptor of the file that was saved.
     *
     * @fires module:Controller.repaint
     */
    onSave(fileDescriptor) {
        const _ = this.controller.gettext;

        if (this.backend.sameFile(fileDescriptor, this.jsonFileDescriptor)) {
            this.jsonNeedsSaving = false;
        }
        else if (this.backend.sameFile(fileDescriptor, this.htmlFileDescriptor)) {
            this.htmlNeedsSaving = false;
        }

        this.controller.emit("repaint"); // TODO move this to controller
        this.controller.info(Jed.sprintf(_("Saved %s."), this.backend.getName(fileDescriptor)));
    }

    /** Extract the data to save from the current presentation and the current editor state.
     *
     * @returns {string} - A JSON representation of the presentation data and editor state.
     */
    getJSONData() {
        const storable = {};
        for (let object of [this.presentation, this.selection, this.controller]) {
            const partial = object.toStorable();
            for (let key in partial) {
                storable[key] = partial[key];
            }
        }
        return JSON.stringify(storable, null, "  ");
    }

    /** Generate the content of the presentation HTML file.
     *
     * The result is derived from the `player.html` template.
     * It contains a copy of the following items:
     * - the SVG document,
     * - the presentation data needed by the player,
     * - a copy of the custom style sheets and scripts.
     *
     * @returns {string} - An HTML document content, as text.
     */
    exportHTML() {
        return nunjucks.render("player.html", {
            svg: this.document.asText,
            pres: this.presentation,
            json: JSON.stringify(this.presentation.toMinimalStorable()),
            customCSS: this.readCustomFiles(".css"),
            customJS: this.readCustomFiles(".js")
        });
    }

    /** Generate the content of the presenter console HTML file.
     *
     * The result is derived from the `presenter.html` template.
     *
     * @param {string} htmlFileName - The name of the presentation HTML file to play.
     * @returns {string} - An HTML document content, as text.
     */
    exportPresenterHTML(htmlFileName) {
        return nunjucks.render("presenter.html", {
            pres: this.presentation,
            soziHtml: htmlFileName
        });
    }

    /** Get the path of a file relative to the location of the current SVG file.
     *
     * @param {string} filePath - The path of a file.
     * @returns {string} - The path of the same file, relative to the location of the current SVG file.
     */
    toRelativePath(filePath) {
        const svgLoc = this.backend.getLocation(this.svgFileDescriptor);
        return path.relative(svgLoc, filePath);
    }

    /** Read custom files to include in the presentation HTML.
     *
     * @param {string} ext - The extension of the files to read.
     * @returns {string} - The concatenated content of all the files read.
     */
    readCustomFiles(ext) {
        const svgLoc = this.backend.getLocation(this.svgFileDescriptor);
        const paths = this.presentation.customFiles.filter(path => path.endsWith(ext));
        const contents = paths.map(relPath => {
            const absPath = path.join(svgLoc, relPath);
            return this.backend.loadSync(absPath);
        });
        return contents.join("\n");
    }
}