1/**
2 * Callback for saving a diagram
3 * @callback saveCallback
4 * @param {string} svg The SVG data to save
5 * @returns {Promise<boolean>|boolean} true if saving was successful, false otherwise
6 */
7
8/**
9 * Callback for when saving has finished suscessfully
10 * @callback postSaveCallback
11 */
12
13
14/**
15 * This class encapsulates all interaction with the diagrams editor
16 *
17 * It manages displaying and communicating with the editor, most importantly in manages loading
18 * and saving diagrams.
19 *
20 * Note: devs should take care to ensure that only ever one instance of this class is active at a time
21 * in the same window.
22 *
23 * FIXME we're not catching any fetch exceptions currently. Should we?
24 * @class
25 */
26class DiagramsEditor {
27    /** @type {HTMLIFrameElement} the editor iframe */
28    #diagramsEditor = null;
29
30    /** @type {saveCallback} the method to call for saving the diagram */
31    #saveCallback = null;
32
33    /** @type {postSaveCallback} called when saving has finished*/
34    #postSaveCallback = null;
35
36    /** @type {string} the initial save data to load, set by one of the edit* methods */
37    #svg = '';
38
39    /** @type {function} the bound message listener */
40    #listener = null;
41
42    /**
43     * Create a new diagrams editor
44     *
45     * @param {postSaveCallback} postSaveCallback Called when saving has finished
46     */
47    constructor(postSaveCallback = null) {
48        this.#postSaveCallback = postSaveCallback;
49    }
50
51    /**
52     * Initialize the editor for editing a media file
53     *
54     * @param {string} mediaid The media ID to edit, if 404 a new file will be created
55     */
56    async editMediaFile(mediaid) {
57        this.#saveCallback = (svg) => this.#saveMediaFile(mediaid, svg);
58
59        const response = await fetch(DOKU_BASE + 'lib/exe/fetch.php?media=' + mediaid, {
60            method: 'GET',
61            cache: 'no-cache',
62        });
63
64        if (response.ok) {
65            // if not 404, load the SVG data
66            this.#svg = await response.text();
67        }
68
69        this.#createEditor();
70    }
71
72    /**
73     * Initialize the editor for editing an embedded diagram
74     *
75     * @param {string} pageid The page ID to on which the diagram is embedded
76     * @param {int} position The position of the diagram in the page
77     * @param {int} length The length of the diagram in the page
78     */
79    async editEmbed(pageid, position, length) {
80        this.#saveCallback = (svg) => this.#saveEmbed(pageid, position, length, svg);
81
82        const url = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_load' +
83            '&id=' + encodeURIComponent(pageid) +
84            '&pos=' + encodeURIComponent(position) +
85            '&len=' + encodeURIComponent(length);
86
87        const response = await fetch(url, {
88            method: 'GET',
89            cache: 'no-cache',
90        });
91
92        if (response.ok) {
93            // if not 404, load the SVG data
94            this.#svg = await response.text();
95        } else {
96            // a 404 for an embedded diagram should not happen
97            alert(LANG.plugins.diagrams.errorLoading);
98            return;
99        }
100
101        this.#createEditor();
102    }
103
104    /**
105     * Initialize the editor for editing a diagram in memory
106     *
107     * @param {string} svg The SVG raw data to edit, empty for new file
108     * @param {saveCallback} callback The callback to call when the editor is closed
109     */
110    editMemory(svg, callback) {
111        this.#svg = svg;
112        this.#saveCallback = callback.bind(this);
113        this.#createEditor();
114    }
115
116    /**
117     * Saves a diagram as a media file
118     *
119     * @param {string} mediaid The media ID to save
120     * @param {string} svg The SVG raw data to save
121     * @returns {Promise<boolean>}
122     */
123    async #saveMediaFile(mediaid, svg) {
124        const uploadUrl = this.#mediaUploadUrl(mediaid);
125
126        const response = await fetch(uploadUrl, {
127            method: 'POST',
128            cache: 'no-cache',
129            body: svg,
130        });
131
132        return response.ok;
133    }
134
135    /**
136     * Saves a diagram as an embedded diagram
137     *
138     * This replaces the previous diagram at the given postion
139     *
140     * @param {string} pageid The page ID on which the diagram is embedded
141     * @param {int} position The position of the diagram in the page
142     * @param {int} length The length of the diagram as it was before
143     * @param {string} svg The SVG raw data to save
144     * @returns {Promise<boolean>}
145     */
146    async #saveEmbed(pageid, position, length, svg) {
147        const uploadUrl = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_embed_save' +
148            '&id=' + encodeURIComponent(pageid) +
149            '&pos=' + encodeURIComponent(position) +
150            '&len=' + encodeURIComponent(length) +
151            '&sectok=' + JSINFO['sectok'];
152
153        const body = new FormData();
154        body.set('svg', svg);
155
156        const response = await fetch(uploadUrl, {
157            method: 'POST',
158            cache: 'no-cache',
159            body: body,
160        });
161
162        return response.ok;
163    }
164
165    /**
166     * Save the PNG cache for a diagram
167     *
168     * @param {string} svg
169     * @param {string} png
170     * @returns {Promise<boolean>}
171     */
172    async #savePngCache(svg, png) {
173        const uploadUrl = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_diagrams_savecache' +
174            '&sectok=' + JSINFO['sectok'];
175
176        const body = new FormData();
177        body.set('svg', svg);
178        body.set('png', png);
179
180        const response = await fetch(uploadUrl, {
181            method: 'POST',
182            cache: 'no-cache',
183            body: body,
184        });
185
186        return response.ok;
187    }
188
189    /**
190     * Create the editor iframe and attach the message listener
191     */
192    #createEditor() {
193        this.#diagramsEditor = document.createElement('iframe');
194        this.#diagramsEditor.id = 'plugin__diagrams-editor';
195        this.#diagramsEditor.src = JSINFO['plugins']['diagrams']['service_url'];
196        document.body.appendChild(this.#diagramsEditor);
197
198        this.#listener = this.#handleMessage.bind(this);
199        window.addEventListener('message', this.#listener);
200    }
201
202    /**
203     * Remove the editor iframe and detach the message listener
204     */
205    #removeEditor() {
206        if (this.#diagramsEditor === null) return;
207        this.#diagramsEditor.remove();
208        this.#diagramsEditor = null;
209        window.removeEventListener('message', this.#listener);
210    }
211
212    /**
213     * Get the raw data from a data URI
214     *
215     * @param {string} dataUri
216     * @returns {string|null}
217     */
218    #decodeDataUri(dataUri) {
219        const matches = dataUri.match(/^data:(.*);base64,(.*)$/);
220        if (matches === null) return null;
221
222        return decodeURIComponent(
223            atob(matches[2])
224                .split('')
225                .map(c => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
226                .join('')
227        );
228    }
229
230    /**
231     * Handle messages from diagramming service
232     *
233     * @param {Event} event
234     */
235    async #handleMessage(event) {
236        const msg = JSON.parse(event.data);
237
238        switch (msg.event) {
239            case 'init':
240                // load the SVG data into the editor
241                this.#diagramsEditor.contentWindow.postMessage(JSON.stringify({action: 'load', xml: this.#svg}), '*');
242                break;
243            case 'save':
244                this.#svg = '';
245
246                // Save triggers an export to SVG action
247                this.#diagramsEditor.contentWindow.postMessage(
248                    JSON.stringify({
249                        action: 'export',
250                        format: 'xmlsvg',
251                        spin: LANG.plugins.diagrams.saving
252                    }),
253                    '*'
254                );
255                break;
256            case 'export':
257                if (msg.format === 'svg') {
258                    this.#svg = this.#decodeDataUri(msg.data);
259
260                    // export again as PNG
261                    this.#diagramsEditor.contentWindow.postMessage(
262                        JSON.stringify({
263                            action: 'export',
264                            format: 'png',
265                            spin: LANG.plugins.diagrams.saving
266                        }),
267                        '*'
268                    );
269                } else if (msg.format === 'png') {
270                    const png = msg.data; // keep as data uri, for binary safety
271                    let ok = await this.#savePngCache(this.#svg, png);
272                    if (!ok) {
273                        alert(LANG.plugins.diagrams.errorSaving);
274                        return;
275                    }
276                    ok = await this.#saveCallback(this.#svg);
277                    if (ok) {
278                        this.#removeEditor();
279                        if (this.#postSaveCallback !== null) {
280                            this.#postSaveCallback();
281                        }
282                    } else {
283                        alert(LANG.plugins.diagrams.errorSaving);
284                    }
285                } else {
286                    alert(LANG.plugins.diagrams.errorUnsupportedFormat);
287                    return;
288                }
289                break;
290            case 'exit':
291                this.#removeEditor();
292                break;
293        }
294    }
295
296    /**
297     * Get the URL to upload a media file
298     * @param {string} mediaid
299     * @returns {string}
300     */
301    #mediaUploadUrl(mediaid) {
302        // split mediaid into namespace and id
303        let id = mediaid;
304        let ns = '';
305        const idParts = id.split(':');
306        if (idParts.length > 1) {
307            id = idParts.pop(idParts);
308            ns = idParts.join(':');
309        }
310
311        return DOKU_BASE +
312            'lib/exe/ajax.php?call=mediaupload&ow=true&ns=' +
313            encodeURIComponent(ns) +
314            '&qqfile=' +
315            encodeURIComponent(id) +
316            '&sectok=' +
317            encodeURIComponent(JSINFO['sectok']);
318    }
319}
320