1/**
2 * The Tree Move Manager
3 *
4 * This script handles the move tree and all its interactions.
5 *
6 * The script supports combined and separate page/media trees. Items have their orignal ID in data-orig and their
7 * current ID in data-id.
8 *
9 * This is pure vanilla JavaScript without any dependencies to jQuery. It is lazy loaded by the main script.
10 */
11class PluginMoveTree {
12    #ENDPOINT = DOKU_BASE + 'lib/exe/ajax.php?call=plugin_move_tree';
13
14    icons = {
15        'close': 'M10,4H4C2.89,4 2,4.89 2,6V18A2,2 0 0,0 4,20H20A2,2 0 0,0 22,18V8C22,6.89 21.1,6 20,6H12L10,4Z',
16        'open': 'M19,20H4C2.89,20 2,19.1 2,18V6C2,4.89 2.89,4 4,4H10L12,6H19A2,2 0 0,1 21,8H21L4,8V18L6.14,10H23.21L20.93,18.5C20.7,19.37 19.92,20 19,20Z',
17        'page': 'M6,2A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6M6,4H13V9H18V20H6V4M8,12V14H16V12H8M8,16V18H13V16H8Z',
18        'media': 'M13,9V3.5L18.5,9M6,2C4.89,2 4,2.89 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V8L14,2H6Z',
19        'rename': 'M18,4V3A1,1 0 0,0 17,2H5A1,1 0 0,0 4,3V7A1,1 0 0,0 5,8H17A1,1 0 0,0 18,7V6H19V10H9V21A1,1 0 0,0 10,22H12A1,1 0 0,0 13,21V12H21V4H18Z',
20        'drag': 'M4 4V22H20V24H4C2.9 24 2 23.1 2 22V4H4M15 7H20.5L15 1.5V7M8 0H16L22 6V18C22 19.11 21.11 20 20 20H8C6.89 20 6 19.1 6 18V2C6 .89 6.89 0 8 0M17 16V14H8V16H17M20 12V10H8V12H20Z',
21    };
22
23    #mainElement;
24    #mediaTree;
25    #pageTree;
26    #dragTarget;
27    #dragIcon;
28
29    /**
30     * Initialize the base tree and attach all event handlers
31     *
32     * @param {HTMLElement} main
33     */
34    constructor(main) {
35        this.#mainElement = main;
36        this.#mediaTree = this.#mainElement.querySelector('.move-media');
37        this.#pageTree = this.#mainElement.querySelector('.move-pages');
38
39
40        this.#dragIcon = this.icon('drag');
41        this.#dragIcon.classList.add('drag-icon');
42        this.#mainElement.appendChild(this.#dragIcon);
43
44        this.#mainElement.addEventListener('click', this.clickHandler.bind(this));
45        this.#mainElement.addEventListener('dragstart', this.dragStartHandler.bind(this));
46        this.#mainElement.addEventListener('dragover', this.dragOverHandler.bind(this));
47        this.#mainElement.addEventListener('drop', this.dragDropHandler.bind(this));
48        this.#mainElement.addEventListener('dragend', this.dragEndHandler.bind(this));
49        this.#mainElement.querySelector('form').addEventListener('submit', this.submitHandler.bind(this));
50
51        // load and open the initial tree
52        this.#init();
53
54        // make tree visible
55        this.#mainElement.style.display = 'block';
56    }
57
58    /**
59     * Initialize the tree
60     *
61     * @returns {Promise<void>}
62     */
63    async #init() {
64        await Promise.all([
65            this.loadSubTree('', 'pages'),
66            this.loadSubTree('', 'media'),
67        ]);
68
69        await this.openNamespace(JSINFO.namespace);
70    }
71
72    /**
73     * Handle all item clicks
74     *
75     * @param {MouseEvent} ev
76     */
77    clickHandler(ev) {
78        const target = ev.target;
79        const li = target.closest('li');
80        if (!li) return;
81
82        // we want to handle clicks on these elements only
83        const clicked = target.closest('i,button,span');
84        if (!clicked) return;
85
86        // ignore clicks on the root element
87        if(li.classList.contains('tree-root')) return;
88
89        // icon click selects the item
90        if (clicked.tagName.toLowerCase() === 'i') {
91            ev.stopPropagation();
92            li.classList.toggle('selected');
93            return;
94        }
95
96        // button click opens rename dialog
97        if (clicked.tagName.toLowerCase() === 'button') {
98            ev.stopPropagation();
99            this.renameGui(li);
100            return;
101        }
102
103        // click on name opens/closes namespace
104        if (clicked.tagName.toLowerCase() === 'span' && li.classList.contains('move-ns')) {
105            ev.stopPropagation();
106            this.toggleNamespace(li);
107        }
108    }
109
110    /**
111     * Submit the data for the move operation
112     *
113     * @param {FormDataEvent} ev
114     */
115    submitHandler(ev) {
116        // gather all changed items
117        const data = [];
118        this.#mainElement.querySelectorAll('.changed').forEach(li => {
119            let entry = {
120                src: li.dataset.orig,
121                dst: li.dataset.id,
122                type: this.isItemMedia(li) ? 'media' : 'page',
123                class: this.isItemNamespace(li) ? 'ns' : 'doc',
124            };
125            data.push(entry);
126
127            // if this is a namspace that is shared between media and pages, add a second entry
128            if (entry.class === 'ns' && entry.type === 'media' && this.isItemPage(li)) {
129                entry = {...entry}; // clone
130                entry.type = 'page';
131                data.push(entry);
132            }
133        });
134
135        // add JSON data to form, then let the event continue
136        const input = document.createElement('input');
137        input.type = 'hidden';
138        input.name = 'json';
139        input.value = JSON.stringify(data);
140        ev.target.appendChild(input);
141    }
142
143    /**
144     * Begin drag operation
145     *
146     * @param {DragEvent} ev
147     */
148    dragStartHandler(ev) {
149        if (!ev.target) return;
150        const li = ev.target.closest('li');
151        if (!li) return;
152
153        ev.dataTransfer.setData('text/plain', li.dataset.id); // FIXME needed?
154        ev.dataTransfer.effectAllowed = 'move';
155        ev.dataTransfer.setDragImage(this.#dragIcon, -12, -12);
156
157        // the dragged element is always selected
158        li.classList.add('selected');
159    }
160
161    /**
162     * Higlight drop zone and allow dropping
163     *
164     * @param {DragEvent} ev
165     */
166    dragOverHandler(ev) {
167        // remove any previous drop zone
168        if (this.#dragTarget) {
169            this.#dragTarget.classList.remove('drop-zone');
170        }
171
172        if (!ev.target) return;  // the element the mouse is over
173
174        const li = ev.target.closest('li');
175        if (!li)  return;
176
177        let ul; // the UL we drop into
178        if (li.classList.contains('move-ns')) {
179            // drop on a namespace, use its UL
180            ul = li.querySelector('ul');
181        } else {
182            // drop on a file or page, use parent UL
183            ul = ev.target.closest('ul');
184        }
185        if (!ul) return;
186        if(ul.classList.contains('open') === false) return; // only drop into open namespaces
187        ev.preventDefault(); // allow drop
188
189        this.#dragTarget = ul;
190        this.#dragTarget.classList.add('drop-zone');
191    }
192
193    /**
194     * Handle the Drop operation
195     *
196     * @param {DragEvent} ev
197     */
198    dragDropHandler(ev) {
199        if (!ev.target) return;
200
201        const dst = this.#dragTarget; // the UL we drop into
202
203        // move all selected items to the drop target
204        const elements = this.#mainElement.querySelectorAll('.selected');
205        elements.forEach(src => {
206            const newID = this.getNewId(src.dataset.id, dst.dataset.id);
207            console.log('move started', src.dataset.id + ' → ' + newID);
208
209            // ensure that item stays in its own tree, ignore cross-tree moves
210            if (this.itemTree(src).contains(dst) === false) {
211                return;
212            }
213
214            // same ID? we consider this an abort
215            if (newID === src.dataset.id) {
216                src.classList.remove('selected');
217                return;
218            }
219
220            // check if item with same ID and type already exists
221            let dupSelector = `li[data-id="${newID}"]`;
222            if (this.isItemMedia(src)) {
223                dupSelector += '.move-media';
224            } else {
225                dupSelector += '.move-pages';
226            }
227            if (this.isItemNamespace(src)) {
228                dupSelector += '.move-ns';
229            } else {
230                dupSelector += ':not(.move-ns)';
231            }
232            if (this.itemTree(src).querySelector(dupSelector)) {
233                alert(LANG.plugins.move.duplicate.replace('%s', newID));
234                src.classList.remove('selected');
235                return;
236            }
237
238            try {
239                dst.append(src);
240            } catch (e) {
241                console.log('move aborted', e.message); // moved into itself
242                src.classList.remove('selected');
243                return;
244            }
245            this.updateMovedItem(src, newID);
246        });
247        this.updatePassiveSubNamespaces(dst);
248        this.sortList(dst);
249    }
250
251    /**
252     * Clean up after drag'n'drop operation
253     *
254     * @param {DragEvent} ev
255     */
256    dragEndHandler(ev) {
257        if (this.#dragTarget) {
258            this.#dragTarget.classList.remove('drop-zone');
259        }
260    }
261
262    /**
263     * Open the given namespace and all its parents
264     *
265     * @param {string} namespace
266     * @returns {Promise<void>}
267     */
268    async openNamespace(namespace) {
269        const namespaces = namespace.split(':');
270
271        for (let i = 0; i < namespaces.length; i++) {
272            const ns = namespaces.slice(0, i + 1).join(':');
273            const li = this.#mainElement.querySelectorAll(`li[data-orig="${ns}"].move-ns`);
274            if (!li.length) return;
275
276            // we might have multiple namespaces with the same ID (media and pages)
277            // we open both in parallel and wait for them
278            const promises = [];
279            for (const el of li) {
280                const ul = el.querySelector('ul');
281                if (!ul) {
282                    promises.push(this.toggleNamespace(el));
283                }
284            }
285            await Promise.all(promises);
286        }
287    }
288
289    /**
290     * Rename an item via a prompt dialog
291     *
292     * @param li
293     */
294    renameGui(li) {
295        const basename = this.getBase(li.dataset.id);
296        const newname = window.prompt(LANG.plugins.move.renameitem, basename);
297        const clean = this.cleanID(newname);
298
299        if (!clean || clean === basename || newname === basename ) {
300            return;
301        }
302
303        // avoid extension changes for media items
304        if (!this.isItemNamespace(li) && this.isItemMedia(li)) {
305            if (this.getExtension(li.dataset.id) !== this.getExtension(clean)) {
306                alert(LANG.plugins.move.extchange);
307                return;
308            }
309        }
310
311        // construct new ID and check for duplicate
312        const ns = this.getNamespace(li.dataset.id);
313        const newID = ns ? ns + ':' + clean : clean;
314        if (this.itemTree(li).querySelector(`li[data-id="${newID}"]`)) {
315            alert(LANG.plugins.move.duplicate.replace('%s', newID));
316            return;
317        }
318
319        // update the item
320        this.updateMovedItem(li, newID);
321
322        // if this was a namespace, update sub namespaces
323        if (this.isItemNamespace(li)) {
324            this.updatePassiveSubNamespaces(li.querySelector('ul'));
325        }
326    }
327
328
329    /**
330     * Open or close a namespace
331     *
332     * @param li
333     * @returns {Promise<void>}
334     */
335    async toggleNamespace(li) {
336        const isOpen = li.classList.toggle('open');
337
338        // swap icon
339        const icon = li.querySelector('i');
340        icon.parentNode.insertBefore(this.icon(isOpen ? 'open' : 'close'), icon);
341        icon.remove();
342
343        if (isOpen) {
344            // check if UL already exists and reuse it
345            let ul = li.querySelector('ul');
346            if (ul) {
347                ul.style.display = '';
348                return;
349            }
350
351            // create new UL
352            ul = document.createElement('ul');
353            ul.classList = li.classList;
354            ul.dataset.id = li.dataset.id;
355            ul.dataset.orig = li.dataset.orig;
356            li.appendChild(ul);
357
358            const promises = [];
359
360            if (li.classList.contains('move-pages')) {
361                promises.push(this.loadSubTree(li.dataset.orig, 'pages'));
362            }
363            if (li.classList.contains('move-media')) {
364                promises.push(this.loadSubTree(li.dataset.orig, 'media'));
365            }
366            await Promise.all(promises);
367        } else {
368            const ul = li.querySelector('ul');
369            if (ul) {
370                ul.style.display = 'none';
371            }
372        }
373    }
374
375    /**
376     * Load the data for a namespace
377     *
378     * @param {string} namespace
379     * @param {string} type
380     * @returns {Promise<void>}
381     */
382    async loadSubTree(namespace, type) {
383
384        const data = new FormData;
385        data.append('ns', namespace);
386        data.append('is_media', type === 'media' ? 1 : 0);
387
388        const response = await fetch(this.#ENDPOINT, {
389            method: 'POST',
390            body: data
391        });
392        const result = await response.json();
393
394        this.renderSubTree(namespace, result, type);
395    }
396
397    /**
398     * Render the data for a namespace
399     *
400     * @param {string} namespace
401     * @param {object[]} data
402     * @param {string} type
403     */
404    renderSubTree(namespace, data, type) {
405        const selector = `ul[data-orig="${namespace}"].move-${type}.move-ns`;
406        const parent = this.#mainElement.querySelector(selector);
407
408        for (const item of data) {
409            let li;
410            // reuse namespace
411            if (item.type === 'd') {
412                li = parent.querySelector(`li[data-orig="${item.id}"].move-ns`);
413            }
414            // create new item
415            if (!li) {
416                li = this.createListItem(item, type);
417                parent.appendChild(li);
418            }
419            // ensure class is added to reused namespaces
420            li.classList.add(`move-${type}`);
421        }
422
423        this.sortList(parent);
424        this.updatePassiveSubNamespaces(parent); // subtree might have been loaded into a renamed namespace
425    }
426
427    /**
428     * Sort the children of the given element
429     *
430     * namespaces are sorted first, then by ID
431     *
432     * @param {HTMLUListElement} parent
433     */
434    sortList(parent) {
435        [...parent.children]
436            .sort((a, b) => {
437                // sort namespaces first
438                if (a.classList.contains('move-ns') && !b.classList.contains('move-ns')) {
439                    return -1;
440                }
441                if (!a.classList.contains('move-ns') && b.classList.contains('move-ns')) {
442                    return 1;
443                }
444                // sort by ID
445                return a.dataset.id.localeCompare(b.dataset.id);
446            })
447            .forEach(node => parent.appendChild(node));
448    }
449
450    /**
451     * Update the IDs of all sub-namespaces without marking them as moved
452     *
453     * The update is not marked as a change, because it will be covered in the move of an upper namespace.
454     * But updating the ID ensures that all drags that go into this namespace will already reflect the new namespace.
455     *
456     * @param {HTMLUListElement} parent
457     */
458    updatePassiveSubNamespaces(parent) {
459        const ns = parent.dataset.id; // parent is the namespace
460
461        for (const li of parent.children) {
462            if (!this.isItemNamespace(li)) continue;
463
464            const newID = this.getNewId(li.dataset.id, ns);
465            li.dataset.id = newID;
466
467            const sub = li.getElementsByTagName('ul');
468            if (sub.length) {
469                sub[0].dataset.id = newID;
470                this.updatePassiveSubNamespaces(sub[0]);
471            }
472        }
473    }
474
475    /**
476     * Get the new ID when moving an item to a new namespace
477     *
478     * @param oldId
479     * @param newNS
480     * @returns {string}
481     */
482    getNewId(oldId, newNS) {
483        const base = this.getBase(oldId);
484        return newNS ? newNS + ':' + base : base;
485    }
486
487    /**
488     * Adjust the ID of a moved item
489     *
490     * @param {HTMLLIElement} li The item to rename
491     * @param {string} newID The new ID
492     */
493    updateMovedItem(li, newID) {
494        const name = li.querySelector('span');
495
496        if (li.dataset.orig === newID) {
497            // item was moved back to its original ID
498            li.classList.remove('changed');
499            name.title = '';
500        } else if (li.dataset.id !== newID) {
501            li.dataset.id = newID;
502            li.classList.add('changed');
503            name.textContent = this.getBase(newID);
504            name.title = li.dataset.orig + ' → ' + newID;
505
506            const ul = li.querySelector('ul');
507            if (ul) {
508                ul.dataset.id = newID;
509            }
510        } else {
511            li.classList.remove('changed');
512            name.title = '';
513        }
514    }
515
516    /**
517     * Check if an item is a namespace item
518     *
519     * @param {HTMLLIElement} li
520     * @returns {boolean}
521     */
522    isItemNamespace(li) {
523        return li.classList.contains('move-ns');
524    }
525
526    /**
527     * Check if an item is a media item
528     *
529     * @param {HTMLLIElement} li
530     * @returns {boolean}
531     */
532    isItemMedia(li) {
533        return li.classList.contains('move-media');
534    }
535
536    /**
537     * Check if an item is a page item
538     *
539     * @param {HTMLLIElement} li
540     * @returns {boolean}
541     */
542    isItemPage(li) {
543        return li.classList.contains('move-pages');
544    }
545
546    /**
547     * Get the tree for the given item
548     *
549     * @param li
550     * @returns {HTMLUListElement}
551     */
552    itemTree(li) {
553        if (this.isItemMedia(li)) {
554            return this.#mediaTree;
555        } else {
556            return this.#pageTree;
557        }
558    }
559
560    /**
561     * Create a list item
562     *
563     * @param {object} item
564     * @param {string} type
565     * @returns {HTMLLIElement}
566     */
567    createListItem(item, type) {
568        const li = document.createElement('li');
569        li.dataset.id = item.id;
570        li.dataset.orig = item.id; // track the original ID
571        li.classList.add(`move-${type}`);
572        li.draggable = true;
573
574        const wrapper = document.createElement('div');
575        wrapper.classList.add('li');
576        li.appendChild(wrapper);
577
578        let icon;
579        if (item.type === 'd') {
580            li.classList.add('move-ns');
581            icon = this.icon('close');
582        } else if (type === 'media') {
583            icon = this.icon('media');
584        } else {
585            icon = this.icon('page');
586        }
587        icon.title = LANG.plugins.move.select;
588        wrapper.appendChild(icon);
589
590        const name = document.createElement('span');
591        name.textContent = this.getBase(item.id);
592        wrapper.appendChild(name);
593
594        const renameBtn = document.createElement('button');
595        this.icon('rename', renameBtn);
596        renameBtn.title = LANG.plugins.move.renameitem;
597        wrapper.appendChild(renameBtn);
598
599        return li;
600    }
601
602    /**
603     * Create an icon element
604     *
605     * @param {string} type
606     * @param {HTMLElement} element The element to insert the SVG into, a new <i> if not given
607     * @returns {HTMLElement}
608     */
609    icon(type, element = null) {
610        if (!element) {
611            element = document.createElement('i');
612        }
613
614        element.classList.add('icon');
615        element.innerHTML = `<svg viewBox="0 0 24 24"><path d="${this.icons[type]}" /></svg>`;
616        return element;
617    }
618
619    /**
620     * Get the base part (filename) of an ID
621     *
622     * @param {string} id
623     * @returns {string}
624     */
625    getBase(id) {
626        return id.split(':').slice(-1)[0];
627    }
628
629    /**
630     * Get the extension part of an ID
631     *
632     * This isn't perfect, but adds some safety
633     *
634     * @param {string} id
635     * @returns {string}
636     */
637    getExtension(id) {
638        const parts = id.split('.');
639        return parts.length > 1 ? parts.pop() : '';
640    }
641
642    /**
643     * Get the namespace part of an ID
644     *
645     * @param {string} id
646     * @returns {string}
647     */
648    getNamespace(id) {
649        if (id.includes(':') === false) {
650            return '';
651        }
652        return id.split(':').slice(0, -1).join(':');
653    }
654
655    /**
656     * Very simplistic cleanID() in JavaScript
657     *
658     * Strips out namespaces
659     *
660     * @param {string} id
661     */
662    cleanID(id) {
663        if (!id) return '';
664
665        id = id.replace(/[!"#$%§&'()+,\/;<=>?@\[\]^`{|}~\\:*\s]+/g, '_');
666        id = id.replace(/^_+/, '');
667        id = id.replace(/_+$/, '');
668        id = id.toLowerCase();
669
670        return id;
671    };
672}
673