Jump to content
Toggle menu
Toggle preferences menu
Toggle personal menu
Not logged in
If you make an edit, a temporary account will be created for you.

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

MediaWiki interface page
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
Line 3: Line 3:


     const PreloadEditor = {
     const PreloadEditor = {
         version: '0.6.0',
         version: '0.7.0',


         state: {
         state: {
Line 13: Line 13:
             conditions: [],
             conditions: [],
             selectedVariable: 'PAGE',
             selectedVariable: 'PAGE',
            deletedVariables: [],
             mode: 'idle',
             mode: 'idle',
             dirty: false
             dirty: false
Line 25: Line 26:
         editorTools: null,
         editorTools: null,


        /*
        * Browser-local draft storage.
        *
        * This is deliberately separate from the wiki.
        * Nothing is saved to MediaWiki until the user
        * explicitly clicks one of the Save buttons.
        */
         draftStorageKey:
         draftStorageKey:
             'PetitPlanet.PreloadEditor.drafts.v1',
             'PetitPlanet.PreloadEditor.drafts.v1',
Line 112: Line 106:
         getDraftId(title) {
         getDraftId(title) {
             title =
             title =
                 String(title || '')
                 String(title || '').trim();
                    .trim();
 
            /*
            * Untitled drafts are still useful for a new preload
            * before the user has entered/saved a title.
            */
            if (!title) {
                return '__untitled__';
            }


             return title.toLowerCase();
             return title
                ? title.toLowerCase()
                : '__untitled__';
         },
         },


Line 130: Line 117:
                 this.getEditorContent();
                 this.getEditorContent();


            /*
            * If CodeMirror is not mounted yet, use the state
            * value instead.
            */
             if (
             if (
                 !this.editorView &&
                 !this.editorView &&
Line 169: Line 152:
                 preloadTitle:
                 preloadTitle:
                     String(
                     String(
                         this.state.preloadTitle ||
                         this.state.preloadTitle || ''
                        ''
                     ),
                     ),


                 preloadSource:
                 preloadSource:
                     String(
                     String(
                         this.state.preloadSource ||
                         this.state.preloadSource || ''
                        ''
                     ),
                     ),


                 preloadContent:
                 preloadContent:
                     String(
                     String(
                         content ||
                         content || ''
                        ''
                     ),
                     ),


Line 206: Line 186:
                         'PAGE'
                         'PAGE'
                     ),
                     ),
                deletedVariables:
                    Array.isArray(
                        this.state.deletedVariables
                    )
                        ? this.state.deletedVariables.slice()
                        : [],


                 mode:
                 mode:
Line 219: Line 206:


         saveDraft() {
         saveDraft() {
            /*
            * Do not save while the editor is merely showing
            * a loaded/saved page with no local changes.
            */
             if (!this.state.dirty) {
             if (!this.state.dirty) {
                 return;
                 return;
Line 241: Line 224:
                 snapshot;
                 snapshot;


            /*
            * Remember which draft was being worked on most
            * recently. This is what lets the editor restore
            * the right thing after a crash/reload.
            */
             storage.active = id;
             storage.active = id;


Line 258: Line 236:


             const id =
             const id =
                 this.getDraftId(
                 this.getDraftId(title);
                    title
                );


             if (
             if (
Line 293: Line 269:
             }
             }


            /*
            * Prefer the explicitly active draft.
            */
             if (
             if (
                 storage.active &&
                 storage.active &&
Line 311: Line 284:
             }
             }


            /*
            * Fallback: find the most recently saved draft.
            */
             let latestId = null;
             let latestId = null;
             let latestDraft = null;
             let latestDraft = null;
Line 323: Line 293:
                     if (
                     if (
                         !draft ||
                         !draft ||
                         typeof draft !==
                         typeof draft !== 'object'
                            'object'
                     ) {
                     ) {
                         return;
                         return;
Line 348: Line 317:
             );
             );


             if (
             if (!latestDraft) {
                !latestDraft
            ) {
                 return null;
                 return null;
             }
             }
Line 360: Line 327:
         },
         },


         restoreDraft() {
         async restoreDraft() {
             const result =
             const result =
                 this.getMostRecentDraft();
                 this.getMostRecentDraft();
Line 376: Line 343:
             const title =
             const title =
                 String(
                 String(
                     draft.preloadTitle ||
                     draft.preloadTitle || ''
                    ''
                 ).trim();
                 ).trim();


Line 394: Line 360:
                 'Preload Editor found unsaved local work for ' +
                 'Preload Editor found unsaved local work for ' +
                 displayTitle +
                 displayTitle +
                 (savedAt
                 (
                    ? '\n\nLast saved locally: ' +
                    savedAt
                      savedAt
                        ? '\n\nLast saved locally: ' +
                    : '') +
                          savedAt
                        : ''
                ) +
                 '\n\nRestore it?';
                 '\n\nRestore it?';


             const restore =
             const restore =
                 window.confirm(
                 await this.createConfirmDialog(
                     message
                    'Restore unsaved work',
                     message,
                    'Restore',
                    'Discard'
                 );
                 );


             if (!restore) {
             if (!restore) {
                /*
                 this.clearDraft(title);
                * The user explicitly rejected recovery,
                * so remove that draft instead of asking
                * about the same stale work on every reload.
                */
                 this.clearDraft(
                    title
                );
 
                 return false;
                 return false;
             }
             }
Line 423: Line 386:
             if (
             if (
                 draft.config &&
                 draft.config &&
                 typeof draft.config ===
                 typeof draft.config === 'object' &&
                    'object' &&
                 !Array.isArray(draft.config)
                 !Array.isArray(
                    draft.config
                )
             ) {
             ) {
                 try {
                 try {
Line 438: Line 398:
                             )
                             )
                         );
                         );
                 } catch (
                 } catch (error) {
                    error
                ) {
                     console.warn(
                     console.warn(
                         'Preload Editor: Could not restore local config.',
                         'Preload Editor: Could not restore local config.',
Line 454: Line 412:
                 preloadSource:
                 preloadSource:
                     String(
                     String(
                         draft.preloadSource ||
                         draft.preloadSource || ''
                        ''
                     ),
                     ),


                 preloadContent:
                 preloadContent:
                     String(
                     String(
                         draft.preloadContent ||
                         draft.preloadContent || ''
                        ''
                     ),
                     ),


Line 486: Line 442:
                     ),
                     ),


                 mode:
                deletedVariables:
                     draft.mode ||
                    Array.isArray(
                     'edit',
                        draft.deletedVariables
                    )
                        ? draft.deletedVariables
                            .map(
                                name =>
                                    String(
                                        name
                                    ).toUpperCase()
                            )
                            .filter(
                                name =>
                                    name !==
                                    'PAGE'
                            )
                        : [],
 
                 mode:
                     draft.mode ||
                     'edit',


                 dirty: true
                 dirty: true
             };
             };


            /*
            * Reanalyze from the actual restored preload.
            * This also recreates any variable configuration
            * that may have been introduced before the crash.
            */
             this.reanalyze();
             this.reanalyze();
             this.renderEditor();
             this.renderEditor();


Line 520: Line 488:


         async getPage(title) {
         async getPage(title) {
             const response = await new mw.Api().get({
             const response =
                action: 'query',
                await new mw.Api().get({
                prop: 'revisions',
                    action: 'query',
                titles: title,
                    prop: 'revisions',
                rvprop: 'content',
                    titles: title,
                rvslots: 'main',
                    rvprop: 'content',
                formatversion: 2
                    rvslots: 'main',
            });
                    formatversion: 2
                });


             const page =
             const page =
Line 535: Line 504:
                 response.query.pages[0];
                 response.query.pages[0];


             if (!page || page.missing) return null;
             if (!page || page.missing) {
                return null;
            }


             const content =
             const content =
Line 545: Line 516:


             return {
             return {
                 title: page.title || title,
                 title:
                    page.title ||
                    title,
 
                 content:
                 content:
                     typeof content === 'string'
                     typeof content === 'string'
Line 558: Line 532:


         async searchPages(query) {
         async searchPages(query) {
             const response = await new mw.Api().get({
             const response =
                action: 'query',
                await new mw.Api().get({
                list: 'search',
                    action: 'query',
                srsearch: query,
                    list: 'search',
                srlimit: 10,
                    srsearch: query,
                srnamespace: 10,
                    srlimit: 10,
                formatversion: 2
                    srnamespace: 10,
            });
                    formatversion: 2
                });


             return (
             return (
Line 621: Line 596:


             while (
             while (
                 (match = pattern.exec(content)) !==
                 (match = pattern.exec(content)) !== null
                null
             ) {
             ) {
                 const name =
                 const name =
Line 643: Line 617:


             while (
             while (
                 (match = pattern.exec(content)) !==
                 (match = pattern.exec(content)) !== null
                null
             ) {
             ) {
                 conditions.push({
                 conditions.push({
                     type:
                     type:
                         match[1].toLowerCase(),
                         match[1].toLowerCase(),
                     expression:
                     expression:
                         match[2].trim(),
                         match[2].trim(),
                     index: match.index
 
                     index:
                        match.index
                 });
                 });
             }
             }
Line 664: Line 640:
                 condition => {
                 condition => {
                     if (
                     if (
                         condition.type !==
                         condition.type !== 'if' &&
                            'if' &&
                         condition.type !== 'elseif'
                         condition.type !==
                            'elseif'
                     ) {
                     ) {
                         return;
                         return;
Line 677: Line 651:
                         ) || [];
                         ) || [];


                     matches.forEach(name => {
                     matches.forEach(
                        name =
                        name => {
                            name.toUpperCase();
                            name =
                                name.toUpperCase();


                        if (
                            if (
                            !variables.includes(
                                !variables.includes(
                                name
                                    name
                            )
                                )
                        ) {
                            ) {
                            variables.push(
                                variables.push(
                                name
                                    name
                            );
                                );
                            }
                         }
                         }
                     });
                     );
                 }
                 }
             );
             );
Line 700: Line 676:
             return {
             return {
                 variables:
                 variables:
                     this.findVariables(
                     this.findVariables(content),
                        content
                    ),


                 conditions:
                 conditions:
                     this.findConditions(
                     this.findConditions(content),
                        content
                    ),


                 conditionVariables:
                 conditionVariables:
Line 754: Line 726:
             if (
             if (
                 !config.settings ||
                 !config.settings ||
                 typeof config.settings !==
                 typeof config.settings !== 'object' ||
                    'object' ||
                 Array.isArray(config.settings)
                 Array.isArray(
             ) {
                    config.settings
                )
             ) {
                 config.settings = {};
                 config.settings = {};
             }
             }
Line 765: Line 734:
             if (
             if (
                 !config.fields ||
                 !config.fields ||
                 typeof config.fields !==
                 typeof config.fields !== 'object' ||
                    'object' ||
                 Array.isArray(config.fields)
                 Array.isArray(
                    config.fields
                )
             ) {
             ) {
                 config.fields = {};
                 config.fields = {};
             }
             }


            /*
            * PAGE is always a special,
            * automatic field.
            *
            * Its configuration is precompiled
            * and cannot be edited.
            */
             config.fields.PAGE =
             config.fields.PAGE =
                 this.getAutomaticPageField();
                 this.getAutomaticPageField();
Line 793: Line 752:
             return {
             return {
                 label:
                 label:
                     this.humanizeName(
                     this.humanizeName(name),
                        name
                    ),


                 description: '',
                 description: '',
Line 835: Line 792:
         ) {
         ) {
             config =
             config =
                 this.normalizeConfig(
                 this.normalizeConfig(config);
                    config
                );


             const existing =
             const existing =
                 config.fields || {};
                 config.fields || {};
            const deleted =
                new Set(
                    Array.isArray(
                        this.state.deletedVariables
                    )
                        ? this.state.deletedVariables
                            .map(
                                name =>
                                    String(
                                        name
                                    ).toUpperCase()
                            )
                        : []
                );


             const fields = {};
             const fields = {};


            /*
            * PAGE is always automatic.
            */
             fields.PAGE =
             fields.PAGE =
                 this.getAutomaticPageField();
                 this.getAutomaticPageField();


            /*
            * Add every variable currently
            * detected in the preload.
            *
            * Existing configuration is
            * preserved.
            *
            * New variables receive a
            * default configuration.
            */
             analysis.variables.forEach(
             analysis.variables.forEach(
                 name => {
                 name => {
                     if (name === 'PAGE') {
                     if (
                        name === 'PAGE' ||
                        deleted.has(name)
                    ) {
                         return;
                         return;
                     }
                     }
Line 875: Line 835:
             );
             );


            /*
            * Preserve existing fields even
            * if they are currently unused.
            *
            * This allows users to keep
            * configuration around while
            * editing the preload.
            */
             Object.keys(existing).forEach(
             Object.keys(existing).forEach(
                 name => {
                 name => {
Line 890: Line 842:
                     if (
                     if (
                         upper !== 'PAGE' &&
                         upper !== 'PAGE' &&
                         !fields[upper]
                         !fields[upper] &&
                        !deleted.has(upper)
                     ) {
                     ) {
                         fields[upper] =
                         fields[upper] =
Line 926: Line 879:
             if (
             if (
                 !config.fields ||
                 !config.fields ||
                 typeof config.fields !==
                 typeof config.fields !== 'object' ||
                    'object' ||
                 Array.isArray(config.fields)
                 Array.isArray(
                    config.fields
                )
             ) {
             ) {
                 return {
                 return {
Line 952: Line 902:
                 name => {
                 name => {
                     if (
                     if (
                         !configured.includes(
                         !configured.includes(name)
                            name
                        )
                     ) {
                     ) {
                         errors.push(
                         errors.push(
Line 982: Line 930:


             if (
             if (
                 !configured.includes(
                 !configured.includes('PAGE')
                    'PAGE'
                )
             ) {
             ) {
                 errors.push(
                 errors.push(
Line 997: Line 943:
                     if (
                     if (
                         !field ||
                         !field ||
                         typeof field !==
                         typeof field !== 'object' ||
                            'object' ||
                         Array.isArray(field)
                         Array.isArray(field)
                     ) {
                     ) {
Line 1,020: Line 965:
                     if (
                     if (
                         (
                         (
                             field.type ===
                             field.type === 'select' ||
                                'select' ||
                             field.type === 'multiselect'
                             field.type ===
                                'multiselect'
                         ) &&
                         ) &&
                         !field.source &&
                         !field.source &&
                         !Array.isArray(
                         !Array.isArray(field.options)
                            field.options
                        )
                     ) {
                     ) {
                         warnings.push(
                         warnings.push(
Line 1,106: Line 1,047:


             if (className) {
             if (className) {
                 section.addClass(
                 section.addClass(className);
                    className
                );
             }
             }


Line 1,138: Line 1,077:


             if (className) {
             if (className) {
                 button.addClass(
                 button.addClass(className);
                    className
                );
             }
             }


Line 1,258: Line 1,195:
                             ) ===
                             ) ===
                             String(
                             String(
                                 value ===
                                 value === undefined
                                    undefined
                                     ? ''
                                     ? ''
                                     : value
                                     : value
                             )
                             )
                         )
                         )
                         .appendTo(
                         .appendTo(select);
                            select
                        );
                 }
                 }
             );
             );
Line 1,273: Line 1,207:
         },
         },


         // CodeMirror
         // In-wiki dialogs


         destroyEditor() {
         createConfirmDialog(
             if (!this.editor) {
            title,
                this.editorView = null;
            message,
                this.editorTools = null;
            confirmLabel,
                return;
            cancelLabel
            }
        ) {
             return new Promise(
                resolve => {
                    const overlay =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );


            const {
                    const box =
                view,
                        $('<div>')
                inputHandler
                            .addClass(
            } = this.editor;
                                'preload-editor-prompt'
                            );


            if (
                    $('<h2>')
                view &&
                        .text(title)
                inputHandler
                        .appendTo(box);
            ) {
                view.dom.removeEventListener(
                    'input',
                    inputHandler
                );
            }


            if (view) {
                    const messageLines =
                view.destroy();
                        String(message || '')
            }
                            .split('\n');


            this.editor = null;
                    messageLines.forEach(
            this.editorView = null;
                        line => {
            this.editorTools = null;
                            $('<p>')
        },
                                .text(line)
                                .appendTo(box);
                        }
                    );


        getEditorView() {
                    const buttons =
            return this.editorView;
                        $('<div>')
        },
                            .addClass(
                                'preload-editor-prompt-buttons'
                            );


        getEditorContent() {
                    const cancel =
            /*
                        this.createButton(
            * Always read directly from the
                            cancelLabel || 'Cancel',
            * CodeMirror state when available.
                            '',
            *
                            () => {
            * This is important because the
                                overlay.remove();
            * native textarea may not contain
                                resolve(false);
            * the actual current document.
                            }
            */
                        );
            if (
                this.editorView &&
                this.editorView.state
            ) {
                return this.editorView.state.doc.toString();
            }


            return (
                    const confirm =
                this.state.preloadContent ||
                        this.createButton(
                ''
                            confirmLabel || 'Confirm',
            );
                            'preload-editor-button-primary',
        },
                            () => {
                                overlay.remove();
                                resolve(true);
                            }
                        );


        setEditorContent(content) {
                    buttons.append(
            content =
                        cancel,
                String(content || '');
                        confirm
                    );


            if (!this.editorView) {
                    box.append(buttons);
                this.state.preloadContent =
                    overlay.append(box);
                    content;


                return;
                    $(document.body)
            }
                        .append(overlay);
 
                    const close =
                        value => {
                            if (value === 'Escape') {
                                cancel.trigger('click');
                            }
                        };
 
                    overlay.on(
                        'keydown.preloadEditor',
                        event => {
                            close(event.key);
                        }
                    );


            const current =
                    overlay.attr(
                this.editorView.state.doc.toString();
                        'tabindex',
                        '-1'
                    );


            if (current === content) {
                    overlay.trigger('focus');
                return;
            }


            this.editorView.dispatch({
                    /*
                changes: {
                    * Keep the browser from submitting or
                    from: 0,
                    * otherwise handling Enter/Escape itself.
                    to:
                    */
                        this.editorView.state
                    overlay.on(
                             .doc.length,
                        'click.preloadEditor',
                     insert: content
                        event => {
                            if (
                                event.target ===
                                overlay.get(0)
                            ) {
                                cancel.trigger('click');
                             }
                        }
                     );
                 }
                 }
             });
             );
         },
         },


         getEditorSelection() {
         createPromptDialog(
             if (
            title,
                 !this.editorView ||
            message
                !this.editorView.state
        ) {
            ) {
             return new Promise(
                return null;
                 resolve => {
            }
                    const overlay =
 
                        $('<div>')
            const selection =
                            .addClass(
                this.editorView.state
                                'preload-editor-prompt-overlay'
                    .selection.main;
                            );
 
                    const box =
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt'
                            );


            return {
                    $('<h2>')
                from:
                        .text(title)
                    selection.from,
                        .appendTo(box);


                to:
                    $('<p>')
                    selection.to,
                        .text(message)
                        .appendTo(box);


                text:
                    const input =
                    this.editorView.state.doc.sliceString(
                        this.textInput();
                        selection.from,
                        selection.to
                    )
            };
        },


        focusEditor() {
                    const buttons =
            if (this.editorView) {
                        $('<div>')
                this.editorView.focus();
                            .addClass(
            }
                                'preload-editor-prompt-buttons'
        },
                            );


        insertEditorText(
                    const cancel =
            text,
                        this.createButton(
            selectionFrom,
                            'Cancel',
            selectionTo,
                            '',
            cursorPosition
                            () => {
        ) {
                                overlay.remove();
            if (!this.editorView) {
                                resolve(null);
                return;
                            }
            }
                        );


            const view =
                    const confirm =
                this.editorView;
                        this.createButton(
                            'Load',
                            'preload-editor-button-primary',
                            () => {
                                const value =
                                    String(
                                        input.val() || ''
                                    ).trim();


            const selection =
                                if (!value) {
                view.state.selection.main;
                                    return;
                                }


            const from =
                                overlay.remove();
                selectionFrom ===
                                resolve(value);
                    undefined
                            }
                    ? selection.from
                        );
                    : selectionFrom;


            const to =
                    buttons.append(
                selectionTo ===
                        cancel,
                    undefined
                        confirm
                    ? selection.to
                     );
                     : selectionTo;


            const cursor =
                    box.append(
                cursorPosition ===
                        input,
                     undefined
                        buttons
                     ? from + text.length
                    );
                     : cursorPosition;
 
                     overlay.append(box);
 
                     $(document.body)
                        .append(overlay);
 
                     input.trigger('focus');


            view.dispatch({
                    input.on(
                changes: {
                        'keydown.preloadEditor',
                    from,
                        event => {
                    to,
                            if (
                    insert: text
                                event.key === 'Enter'
                },
                            ) {
                                confirm.trigger('click');
                            }


                selection: {
                            if (
                     anchor: cursor
                                event.key === 'Escape'
                            ) {
                                cancel.trigger('click');
                            }
                        }
                     );
                 }
                 }
             });
             );
 
            view.focus();
         },
         },


         // V2.3 editor tools
         // CodeMirror


         wrapSelectionWithVariable() {
         destroyEditor() {
             const selected =
             if (!this.editor) {
                this.getEditorSelection();
                 this.editorView = null;
 
                 this.editorTools = null;
            if (
                 !selected ||
                selected.from ===
                    selected.to
            ) {
                 this.focusEditor();
                 return;
                 return;
             }
             }


             const replacement =
             const {
                 '%%' +
                 view,
                 selected.text +
                 inputHandler
                '%%';
            } = this.editor;


             this.insertEditorText(
             if (
                 replacement,
                 view &&
                 selected.from,
                 inputHandler
                 selected.to,
            ) {
                selected.from +
                 view.dom.removeEventListener(
                     replacement.length
                    'input',
            );
                     inputHandler
        },
                );
            }


        insertVariable(name) {
            if (view) {
            name =
                 view.destroy();
                 String(name || '')
                    .trim()
                    .toUpperCase();
 
            if (!name) {
                return;
             }
             }


             const selected =
             this.editor = null;
                this.getEditorSelection();
            this.editorView = null;
            this.editorTools = null;
        },


            if (!selected) {
        getEditorView() {
                return;
            return this.editorView;
            }
        },


             const text =
        getEditorContent() {
                 '%%' +
             if (
                 name +
                 this.editorView &&
                 '%%';
                 this.editorView.state
            ) {
                 return this.editorView.state.doc.toString();
            }


             this.insertEditorText(
             return (
                 text,
                 this.state.preloadContent || ''
                selected.from,
                selected.to
             );
             );
         },
         },


         insertCondition(
         setEditorContent(content) {
            type,
             content =
            variable
                 String(content || '');
        ) {
             type =
                 String(type || '')
                    .toLowerCase();


             variable =
             if (!this.editorView) {
                String(variable || '')
                this.state.preloadContent =
                    .trim()
                     content;
                     .toUpperCase();
                return;
            }


             const selected =
             const current =
                 this.getEditorSelection();
                 this.editorView.state.doc.toString();


             if (!selected) {
             if (current === content) {
                 return;
                 return;
             }
             }


             let text;
             this.editorView.dispatch({
             let cursorOffset;
                changes: {
                    from: 0,
                    to:
                        this.editorView.state.doc.length,
                    insert: content
                }
             });
        },


            switch (type) {
        getEditorSelection() {
                 case 'if': {
            if (
                    if (!variable) {
                 !this.editorView ||
                        return;
                !this.editorView.state
                    }
            ) {
                return null;
            }


                    const opening =
            const selection =
                        '<!-- @if ' +
                this.editorView.state
                        variable +
                    .selection.main;
                        ' -->';


                     const closing =
            return {
                        '<!-- @endif -->';
                from:
                     selection.from,


                    if (
                to:
                        selected.from !==
                     selection.to,
                        selected.to
                     ) {
                        text =
                            opening +
                            '\n' +
                            selected.text +
                            '\n' +
                            closing;


                        cursorOffset =
                text:
                            text.length;
                     this.editorView.state.doc.sliceString(
                     } else {
                         selection.from,
                         text =
                        selection.to
                            opening +
                    )
                            '\n\n' +
            };
                            closing;
        },


                        cursorOffset =
        focusEditor() {
                            opening.length +
            if (this.editorView) {
                            1;
                this.editorView.focus();
                    }
            }
        },


                    break;
        insertEditorText(
                }
            text,
            selectionFrom,
            selectionTo,
            cursorPosition
        ) {
            if (!this.editorView) {
                return;
            }


                case 'elseif':
            const view =
                    text =
                this.editorView;
                        '<!-- @elseif -->';


                    cursorOffset =
            const selection =
                        text.length;
                view.state.selection.main;


                     break;
            const from =
                selectionFrom === undefined
                     ? selection.from
                    : selectionFrom;


                 case 'else':
            const to =
                     text =
                 selectionTo === undefined
                        '<!-- @else -->';
                    ? selection.to
                    : selectionTo;
 
            const cursor =
                cursorPosition === undefined
                     ? from + text.length
                    : cursorPosition;


                     cursorOffset =
            view.dispatch({
                        text.length;
                changes: {
                    from,
                     to,
                    insert: text
                },


                     break;
                selection: {
                     anchor: cursor
                }
            });


                case 'endif':
            view.focus();
                    text =
        },
                        '<!-- @endif -->';


                    cursorOffset =
        // Editor tools
                        text.length;


                    break;
        wrapSelectionWithVariable() {
            const selected =
                this.getEditorSelection();


                 default:
            if (
                    return;
                !selected ||
                 selected.from === selected.to
            ) {
                this.focusEditor();
                return;
             }
             }
            const replacement =
                '%%' +
                selected.text +
                '%%';


             this.insertEditorText(
             this.insertEditorText(
                 text,
                 replacement,
                 selected.from,
                 selected.from,
                 selected.to,
                 selected.to,
                 selected.from +
                 selected.from +
                     cursorOffset
                     replacement.length
             );
             );
         },
         },


         getToolVariables() {
         insertVariable(name) {
             const variables =
             name =
                 new Set(['PAGE']);
                 String(name || '')
                    .trim()
                    .toUpperCase();
 
            if (!name) {
                return;
            }
 
            const selected =
                this.getEditorSelection();


             if (
             if (!selected) {
                this.analysis &&
                 return;
                this.analysis.variables
            ) {
                 this.analysis.variables.forEach(
                    name => {
                        variables.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                );
             }
             }


             return Array.from(
             this.insertEditorText(
                 variables
                '%%' + name + '%%',
                selected.from,
                 selected.to
             );
             );
         },
         },


         createToolSelect(
         insertCondition(
             options,
             type,
             className
             variable
         ) {
         ) {
             const select =
             type =
                 $('<select>')
                 String(type || '')
                     .addClass(
                     .toLowerCase();
                        'preload-editor-tool-select'
                    );


             if (className) {
             variable =
                select.addClass(
                String(variable || '')
                     className
                    .trim()
                );
                     .toUpperCase();
            }


             options.forEach(
             const selected =
                 option => {
                 this.getEditorSelection();
                    $('<option>')
                        .attr(
                            'value',
                            option.value
                        )
                        .text(
                            option.label
                        )
                        .appendTo(
                            select
                        );
                }
            );


            return select;
             if (!selected) {
        },
 
        updateEditorToolVariables() {
             if (!this.editorTools) {
                 return;
                 return;
             }
             }


             const variables =
             let text;
                this.getToolVariables();
            let cursorOffset;


             const current =
             switch (type) {
                 this.editorTools
                 case 'if': {
                    .variableSelect
                     if (!variable) {
                     .val();
                        return;
                    }


            const currentCondition =
                    const opening =
                this.editorTools
                        '<!-- @if ' +
                    .conditionVariableSelect
                        variable +
                    .val();
                        ' -->';


            const populate =
                    const closing =
                (
                         '<!-- @endif -->';
                    select,
                    value
                ) => {
                    select.empty();
 
                    variables.forEach(
                         name => {
                            $('<option>')
                                .attr(
                                    'value',
                                    name
                                )
                                .text(name)
                                .appendTo(
                                    select
                                );
                        }
                    );


                     if (
                     if (
                         value &&
                         selected.from !==
                         variables.includes(
                         selected.to
                            value
                        )
                     ) {
                     ) {
                         select.val(
                         text =
                             value
                            opening +
                        );
                            '\n' +
                    } else if (
                            selected.text +
                         variables.length
                             '\n' +
                     ) {
                            closing;
                         select.val(
 
                             variables[0]
                         cursorOffset =
                         );
                            text.length;
                     } else {
                         text =
                            opening +
                             '\n\n' +
                            closing;
 
                         cursorOffset =
                            opening.length + 1;
                     }
                     }
                };


            populate(
                     break;
                this.editorTools
                 }
                     .variableSelect,
                 current
            );


            populate(
                 case 'elseif':
                 this.editorTools
                     text =
                     .conditionVariableSelect,
                        '<!-- @elseif -->';
                currentCondition
                    cursorOffset =
            );
                        text.length;
        },
                    break;


        updateConditionVariableVisibility() {
                case 'else':
            if (!this.editorTools) {
                    text =
                return;
                        '<!-- @else -->';
            }
                    cursorOffset =
                        text.length;
                    break;


            const type =
                case 'endif':
                this.editorTools
                    text =
                     .conditionTypeSelect
                        '<!-- @endif -->';
                     .val();
                     cursorOffset =
                        text.length;
                     break;


            this.editorTools
                 default:
                .conditionVariableWrap
                     return;
                 .toggle(
            }
                     type === 'if'
                );
        },


        renderEditorTools() {
             this.insertEditorText(
             const tools =
                text,
                $('<div>')
                 selected.from,
                    .addClass(
                selected.to,
                        'preload-editor-tools'
                 selected.from +
                    );
                     cursorOffset
 
            const wrapGroup =
                 $('<div>')
                    .addClass(
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-wrap'
                    );
 
            wrapGroup.append(
                 this.createButton(
                     'Wrap selection with %%',
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
             );
             );
        },


             const variableGroup =
        getToolVariables() {
                 $('<div>')
             const variables =
                    .addClass(
                 new Set(['PAGE']);
                        'preload-editor-tool-group ' +
                        'preload-editor-tool-group-variable'
                    );


             $('<span>')
             if (
                 .addClass(
                 this.analysis &&
                    'preload-editor-tool-label'
                this.analysis.variables
                )
            ) {
                 .text(
                 this.analysis.variables.forEach(
                     'Insert existing variable'
                     name => {
                )
                        variables.add(
                .appendTo(
                            String(
                     variableGroup
                                name
                            ).toUpperCase()
                        );
                     }
                 );
                 );
            }


             const variableSelect =
            return Array.from(variables);
                 this.createToolSelect(
        },
                     [],
 
                    'preload-editor-variable-select'
        createToolSelect(
                );
            options,
            className
        ) {
             const select =
                 $('<select>')
                     .addClass(
                        'preload-editor-tool-select'
                    );


             variableGroup.append(
             if (className) {
                variableSelect,
                select.addClass(className);
            }


                this.createButton(
            options.forEach(
                    'Insert',
                option => {
                     'preload-editor-tool-button',
                     $('<option>')
                    () =>
                         .attr(
                         this.insertVariable(
                             'value',
                             variableSelect.val()
                            option.value
                         )
                         )
                 )
                        .text(option.label)
                        .appendTo(select);
                 }
             );
             );


             const conditionGroup =
             return select;
                $('<div>')
        },
                    .addClass(
 
                        'preload-editor-tool-group ' +
        updateEditorToolVariables() {
                        'preload-editor-tool-group-condition'
            if (!this.editorTools) {
                    );
                return;
            }
 
            const variables =
                this.getToolVariables();


             $('<span>')
             const current =
                 .addClass(
                 this.editorTools
                     'preload-editor-tool-label'
                     .variableSelect
                )
                     .val();
                .text(
                     'Insert condition'
                )
                .appendTo(
                    conditionGroup
                );


             const conditionTypeSelect =
             const currentCondition =
                 this.createToolSelect(
                 this.editorTools
                     [
                     .conditionVariableSelect
                        {
                     .val();
                            value: 'if',
                            label: '@if'
                        },
                        {
                            value: 'elseif',
                            label: '@elseif'
                        },
                        {
                            value: 'else',
                            label: '@else'
                        },
                        {
                            value: 'endif',
                            label: '@endif'
                        }
                     ],
                    'preload-editor-condition-type-select'
                );


             const conditionVariableSelect =
             const populate =
                 this.createToolSelect(
                 (
                     [],
                     select,
                     'preload-editor-condition-variable-select'
                     value
                 );
                 ) => {
                    select.empty();


            const conditionVariableWrap =
                    variables.forEach(
                $('<span>')
                        name => {
                    .addClass(
                            $('<option>')
                        'preload-editor-condition-variable'
                                .attr(
                    )
                                    'value',
                    .append(
                                    name
                         conditionVariableSelect
                                )
                                .text(name)
                                .appendTo(select);
                         }
                     );
                     );


            const insertCondition =
                    if (
                this.createButton(
                        value &&
                     'Insert',
                        variables.includes(value)
                    'preload-editor-tool-button',
                     ) {
                     () =>
                        select.val(value);
                         this.insertCondition(
                     } else if (
                            conditionTypeSelect.val(),
                         variables.length
                             conditionVariableSelect.val()
                    ) {
                         )
                        select.val(
                 );
                             variables[0]
                         );
                    }
                 };


             conditionTypeSelect.on(
             populate(
                 'change.preloadEditor',
                 this.editorTools.variableSelect,
                 () =>
                 current
                    this.updateConditionVariableVisibility()
             );
             );


             conditionGroup.append(
             populate(
                 conditionTypeSelect,
                 this.editorTools.conditionVariableSelect,
                conditionVariableWrap,
                 currentCondition
                 insertCondition
             );
             );
        },


             tools.append(
        updateConditionVariableVisibility() {
                 wrapGroup,
             if (!this.editorTools) {
                variableGroup,
                 return;
                conditionGroup
             }
             );


             this.editorTools = {
             const type =
                root: tools,
                this.editorTools
                variableSelect,
                    .conditionTypeSelect
                conditionTypeSelect,
                    .val();
                conditionVariableSelect,
                conditionVariableWrap,
                insertCondition
            };


             this.updateEditorToolVariables();
             this.editorTools
            this.updateConditionVariableVisibility();
                .conditionVariableWrap
 
                .toggle(
            return tools;
                    type === 'if'
                );
         },
         },


         updateEditorTools() {
         renderEditorTools() {
             this.updateEditorToolVariables();
             const tools =
            this.updateConditionVariableVisibility();
                $('<div>')
        },
                    .addClass(
                        'preload-editor-tools'
                    );


        // Preload syntax highlighting
            const wrapGroup =
                $('<div>')
                    .addClass(
                        'preload-editor-tool-group preload-editor-tool-group-wrap'
                    );


        getKnownVariables() {
            wrapGroup.append(
            const known =
                 this.createButton(
                 new Set(['PAGE']);
                    'Wrap selection with %%',
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
            );


             const fields =
             const variableGroup =
                 this.state.config &&
                 $('<div>')
                 this.state.config.fields;
                    .addClass(
                        'preload-editor-tool-group preload-editor-tool-group-variable'
                    );
 
            $('<span>')
                .addClass(
                    'preload-editor-tool-label'
                )
                 .text(
                    'Insert existing variable'
                )
                .appendTo(variableGroup);


             if (
             const variableSelect =
                fields &&
                 this.createToolSelect(
                typeof fields ===
                     [],
                    'object'
                     'preload-editor-variable-select'
            ) {
                 Object.keys(
                     fields
                ).forEach(
                     name => {
                        known.add(
                            String(
                                name
                            ).toUpperCase()
                        );
                    }
                 );
                 );
            }


             return known;
             variableGroup.append(
        },
                variableSelect,


        createSyntaxDecorations(
                this.createButton(
            state,
                    'Insert',
            Decoration
                    'preload-editor-tool-button',
        ) {
                    () =>
             const decorations = [];
                        this.insertVariable(
                            variableSelect.val()
                        )
                )
             );


             const source =
             const conditionGroup =
                 state.doc.toString();
                 $('<div>')
                    .addClass(
                        'preload-editor-tool-group preload-editor-tool-group-condition'
                    );


             const knownVariables =
             $('<span>')
                 this.getKnownVariables();
                .addClass(
                    'preload-editor-tool-label'
                )
                .text('Insert condition')
                 .appendTo(conditionGroup);


             const variablePattern =
             const conditionTypeSelect =
                 /%%([A-Za-z0-9_-]+)%%/g;
                 this.createToolSelect(
                    [
                        {
                            value: 'if',
                            label: '@if'
                        },
                        {
                            value: 'elseif',
                            label: '@elseif'
                        },
                        {
                            value: 'else',
                            label: '@else'
                        },
                        {
                            value: 'endif',
                            label: '@endif'
                        }
                    ],
                    'preload-editor-condition-type-select'
                );


             let match;
             const conditionVariableSelect =
                this.createToolSelect(
                    [],
                    'preload-editor-condition-variable-select'
                );


             while (
             const conditionVariableWrap =
                 (
                 $('<span>')
                     match =
                     .addClass(
                        variablePattern.exec(
                         'preload-editor-condition-variable'
                            source
                     )
                         )
                     .append(
                ) !== null
                         conditionVariableSelect
            ) {
                const name =
                     match[1].toUpperCase();
 
                const known =
                     knownVariables.has(
                         name
                     );
                     );


                 decorations.push(
            const insertCondition =
                     Decoration.mark({
                 this.createButton(
                        class: [
                     'Insert',
                            'preload-editor-syntax-variable',
                    'preload-editor-tool-button',
                            known
                    () =>
                                ? 'preload-editor-syntax-variable-known'
                         this.insertCondition(
                                : 'preload-editor-syntax-variable-unknown'
                            conditionTypeSelect.val(),
                         ].join(' ')
                             conditionVariableSelect.val()
                    }).range(
                        )
                        match.index,
                        match.index +
                             match[0].length
                    )
                 );
                 );
            }


             const conditionPattern =
             conditionTypeSelect.on(
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                'change.preloadEditor',
                 () =>
                    this.updateConditionVariableVisibility()
            );


             while (
             conditionGroup.append(
                (
                 conditionTypeSelect,
                    match =
                 conditionVariableWrap,
                        conditionPattern.exec(
                insertCondition
                            source
            );
                        )
                 ) !== null
            ) {
                 decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );


                 const directiveStart =
            tools.append(
                    match.index +
                 wrapGroup,
                    match[0].indexOf('@');
                variableGroup,
                conditionGroup
            );


                decorations.push(
            this.editorTools = {
                    Decoration.mark({
                root: tools,
                        class:
                variableSelect,
                            'preload-editor-syntax-directive'
                conditionTypeSelect,
                    }).range(
                conditionVariableSelect,
                        directiveStart,
                conditionVariableWrap,
                        directiveStart +
                insertCondition
                            match[1].length +
            };
                            1
                    )
                );


                const type =
            this.updateEditorToolVariables();
                    match[1].toLowerCase();
            this.updateConditionVariableVisibility();


                if (
            return tools;
                    type !== 'if' &&
        },
                    type !== 'elseif'
                ) {
                    continue;
                }


                const expression =
        updateEditorTools() {
                    match[2];
            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();
        },


                const expressionStart =
        // Syntax highlighting
                    match.index +
                    match[0].indexOf(
                        expression
                    );


                const expressionPattern =
        getKnownVariables() {
                    /[A-Za-z0-9_-]+/g;
            const known =
                new Set(['PAGE']);


                 let expressionMatch;
            const fields =
                 this.state.config &&
                this.state.config.fields;


                while (
            if (
                    (
                fields &&
                        expressionMatch =
                typeof fields === 'object'
                            expressionPattern.exec(
            ) {
                                expression
                Object.keys(fields).forEach(
                            )
                     name => {
                    ) !== null
                         known.add(
                ) {
                             String(
                    const name =
                                name
                        expressionMatch[0]
                            ).toUpperCase()
                            .toUpperCase();
                         );
 
                     if (
                         !knownVariables.has(
                             name
                        )
                    ) {
                         continue;
                     }
                     }
 
                );
                    const start =
                        expressionStart +
                        expressionMatch.index;
 
                    decorations.push(
                        Decoration.mark({
                            class:
                                'preload-editor-syntax-condition-variable'
                        }).range(
                            start,
                            start +
                                expressionMatch[0]
                                    .length
                        )
                    );
                }
             }
             }


             return Decoration.set(
             return known;
                decorations,
                true
            );
         },
         },


         async installSyntaxHighlighting(
         createSyntaxDecorations(
             view
             state,
         ) {
            Decoration
             await mw.loader.using(
         ) {
                 'ext.CodeMirror.v6.lib'
             const decorations = [];
            );
            const source =
                 state.doc.toString();


             const require =
             const knownVariables =
                 mw.loader.require;
                 this.getKnownVariables();


             const {
             const variablePattern =
                StateField,
                 /%%([A-Za-z0-9_-]+)%%/g;
                StateEffect,
                EditorView,
                 Decoration
            } = require(
                'ext.CodeMirror.v6.lib'
            );


             const syntaxField =
             let match;
                StateField.define({
                    create: state =>
                        this.createSyntaxDecorations(
                            state,
                            Decoration
                        ),


                    update: (
            while (
                        decorations,
                (
                        transaction
                     match =
                     ) =>
                         variablePattern.exec(
                         transaction.docChanged
                             source
                             ? this.createSyntaxDecorations(
                        )
                                transaction.state,
                ) !== null
                                Decoration
            ) {
                            )
                const name =
                            : decorations,
                    match[1].toUpperCase();


                    provide: field =>
                const known =
                        EditorView.decorations.from(
                    knownVariables.has(name);
                            field
                        )
                });


            view.dispatch({
                decorations.push(
                effects:
                    Decoration.mark({
                     StateEffect.appendConfig.of(
                        class: [
                         syntaxField
                            'preload-editor-syntax-variable',
                            known
                                ? 'preload-editor-syntax-variable-known'
                                : 'preload-editor-syntax-variable-unknown'
                        ].join(' ')
                     }).range(
                        match.index,
                         match.index +
                            match[0].length
                     )
                     )
             });
                );
             }


             this.editor.syntaxField =
             const conditionPattern =
                 syntaxField;
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
        },


        async mountCodeMirror(
             while (
             textarea
        ) {
            return new Promise(
                 (
                 (
                     resolve,
                     match =
                     reject
                        conditionPattern.exec(
                 ) => {
                            source
                     let settled = false;
                        )
                ) !== null
            ) {
                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-condition'
                    }).range(
                        match.index,
                        match.index +
                            match[0].length
                     )
                 );
 
                const directiveStart =
                     match.index +
                    match[0].indexOf('@');


                     const cleanup =
                decorations.push(
                         () => {
                     Decoration.mark({
                             mw.hook(
                         class:
                                'dev.CCM.ready'
                             'preload-editor-syntax-directive'
                            ).remove(
                    }).range(
                                onReady
                        directiveStart,
                             );
                        directiveStart +
                             match[1].length +
                            1
                    )
                );


                            mw.hook(
                const type =
                                'dev.CCM.load'
                    match[1].toLowerCase();
                            ).remove(
                                onLoad
                            );
                        };


                     const onReady =
                if (
                        async cm => {
                     type !== 'if' &&
                            if (
                    type !== 'elseif'
                                settled ||
                ) {
                                !cm ||
                    continue;
                                cm.textarea !==
                }
                                    textarea ||
                                !cm.view
                            ) {
                                return;
                            }


                            settled = true;
                const expression =
                            cleanup();
                    match[2];


                            const view =
                const expressionStart =
                                cm.view;
                    match.index +
                    match[0].indexOf(
                        expression
                    );


                            const inputHandler =
                const expressionPattern =
                                () => {
                    /[A-Za-z0-9_-]+/g;
                                    this.state
                                        .preloadContent =
                                        view.state.doc.toString();


                                    this.state.dirty =
                let expressionMatch;
                                        true;


                                    /*
                while (
                                    * Persist immediately.
                    (
                                    *
                        expressionMatch =
                                    * This is deliberately not debounced:
                            expressionPattern.exec(
                                    * the purpose of the local draft is
                                expression
                                    * crash recovery.
                            )
                                    */
                    ) !== null
                                    this.saveDraft();
                ) {
                                };
                    const name =
                        expressionMatch[0]
                            .toUpperCase();


                            view.dom.addEventListener(
                    if (
                                'input',
                        !knownVariables.has(name)
                                inputHandler
                    ) {
                            );
                        continue;
                    }


                            view.dom.classList.add(
                    const start =
                                'preload-editor-codemirror'
                        expressionStart +
                            );
                        expressionMatch.index;


                            const wrapper =
                    decorations.push(
                                view.dom.closest(
                        Decoration.mark({
                                    '.ext-codemirror-wrapper'
                            class:
                                 );
                                'preload-editor-syntax-condition-variable'
                        }).range(
                            start,
                            start +
                                 expressionMatch[0]
                                    .length
                        )
                    );
                }
            }


                            if (wrapper) {
            return Decoration.set(
                                wrapper.classList.add(
                decorations,
                                    'preload-editor-codemirror-wrapper'
                true
                                );
            );
                            }
        },


                            this.editor = {
        async installSyntaxHighlighting(view) {
                                cm,
            await mw.loader.using(
                                view,
                'ext.CodeMirror.v6.lib'
                                textarea,
            );
                                inputHandler
                            };


                            this.editorView =
            const require =
                                view;
                mw.loader.require;


                            this.state
            const {
                                .preloadContent =
                StateField,
                                view.state.doc.toString();
                StateEffect,
                EditorView,
                Decoration
            } = require(
                'ext.CodeMirror.v6.lib'
            );


                            try {
            const syntaxField =
                                await this.installSyntaxHighlighting(
                StateField.define({
                                    view
                    create: state =>
                                );
                        this.createSyntaxDecorations(
                            state,
                            Decoration
                        ),


                                /*
                    update: (
                                * Make sure the restored/new
                        decorations,
                                * state is also persisted once
                        transaction
                                * CodeMirror is fully mounted.
                    ) =>
                                */
                        transaction.docChanged
                                 if (
                            ? this.createSyntaxDecorations(
                                    this.state.dirty
                                 transaction.state,
                                 ) {
                                 Decoration
                                    this.saveDraft();
                            )
                                }
                            : decorations,


                                resolve(view);
                    provide: field =>
                            } catch (
                        EditorView.decorations.from(
                                error
                            field
                            ) {
                        )
                                 reject(error);
                });
                             }
 
            view.dispatch({
                effects:
                    StateEffect.appendConfig.of(
                        syntaxField
                    )
            });
 
            this.editor.syntaxField =
                syntaxField;
        },
 
        async mountCodeMirror(textarea) {
            return new Promise(
                (
                    resolve,
                    reject
                ) => {
                    let settled = false;
 
                    const cleanup =
                        () => {
                            mw.hook(
                                 'dev.CCM.ready'
                            ).remove(onReady);
 
                            mw.hook(
                                'dev.CCM.load'
                             ).remove(onLoad);
                         };
                         };


                     const onLoad =
                     const onReady =
                         loader => {
                         async cm => {
                             if (
                             if (
                                 settled ||
                                 settled ||
                                 typeof loader !==
                                 !cm ||
                                    'function'
                                cm.textarea !== textarea ||
                                !cm.view
                             ) {
                             ) {
                                 return;
                                 return;
                             }
                             }


                             try {
                             settled = true;
                                loader(
                            cleanup();
                                    textarea
                                );
                            } catch (
                                error
                            ) {
                                settled = true;
                                cleanup();
                                reject(
                                    error
                                );
                            }
                        };


                    mw.hook(
                            const view =
                        'dev.CCM.ready'
                                cm.view;
                    ).add(
                        onReady
                    );


                    mw.hook(
                            const inputHandler =
                        'dev.CCM.load'
                                () => {
                    ).add(
                                    this.state
                        onLoad
                                        .preloadContent =
                    );
                                        view.state.doc.toString();
                }
            );
        },


        // Main UI
                                    this.state.dirty =
                                        true;


        init() {
                                    this.reanalyze();
            const root =
                $('#mw-content-text');


            if (!root.length) {
                                    this.renderVariablesBySelector();
                return;
                                    this.renderVariableConfigurationBySelector();
            }
                                    this.renderAnalysisBySelector();
                                    this.renderConfigPreviewBySelector();
                                    this.updateEditorTools();


            this.root =
                                    this.saveDraft();
                $('<div>')
                                };
                    .attr(
                        'id',
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                    );


            root.append(
                            view.dom.addEventListener(
                this.root
                                'input',
            );
                                inputHandler
                            );


            this.renderShell();
                            view.dom.classList.add(
                                'preload-editor-codemirror'
                            );


            /*
                            const wrapper =
            * Restore after the shell exists.
                                view.dom.closest(
            *
                                    '.ext-codemirror-wrapper'
            * This means a crash/reload can immediately
                                );
            * bring the previous working document back.
            */
            this.restoreDraft();


            /*
                            if (wrapper) {
            * Extra safety net for normal reloads/navigation.
                                wrapper.classList.add(
            *
                                    'preload-editor-codemirror-wrapper'
            * Browser crashes cannot always fire beforeunload,
                                );
            * which is why we also persist on every edit.
                            }
            */
            window.addEventListener(
                'beforeunload',
                () => {
                    if (
                        this.state.dirty
                    ) {
                        this.saveDraft();
                    }
                }
            );
        },


        renderShell() {
                            this.editor = {
            this.destroyEditor();
                                cm,
            this.root.empty();
                                view,
                                textarea,
                                inputHandler
                            };


            const loader =
                            this.editorView =
                this.createSection(
                                view;
                    'Preload'
 
                );
                            this.state.preloadContent =
                                view.state.doc.toString();


            const titleInput =
                            try {
                this.textInput(
                                await this.installSyntaxHighlighting(
                    this.state.preloadTitle,
                                    view
                    'Template:Example/Preload'
                                );
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );


            /*
                                if (
            * Keep the title in state for new/untitled drafts
                                    this.state.dirty
            * as well. This makes crash recovery more useful
                                ) {
            * before the preload has ever been saved.
                                    this.saveDraft();
            */
                                }
            titleInput.on(
                'input.preloadEditor',
                () => {
                    this.state.preloadTitle =
                        String(
                            titleInput.val() ||
                            ''
                        ).trim();


                    if (
                                resolve(view);
                        this.state.dirty &&
                             } catch (error) {
                        this.state.mode ===
                                reject(error);
                             'new'
                            }
                    ) {
                        };
                        this.saveDraft();
                    }
                }
            );


            const loadButton =
                    const onLoad =
                this.createButton(
                        loader => {
                    'Load',
                            if (
                    'preload-editor-button-primary',
                                settled ||
                    () =>
                                typeof loader !==
                        this.loadExisting(
                                    'function'
                             titleInput.val()
                            ) {
                        )
                                return;
                );
                            }
 
                            try {
                                loader(textarea);
                             } catch (error) {
                                settled = true;
                                cleanup();
                                reject(error);
                            }
                        };


            const newButton =
                    mw.hook(
                this.createButton(
                        'dev.CCM.ready'
                    'New',
                     ).add(onReady);
                     '',
                    () =>
                        this.startNew()
                );


            const sampleButton =
                    mw.hook(
                this.createButton(
                        'dev.CCM.load'
                    'Load Sample Page',
                     ).add(onLoad);
                    '',
                 }
                     () =>
            );
                        this.loadSamplePage()
        },
                 );


             loader.append(
        // Main UI
                 $('<div>')
 
                    .addClass(
        init() {
                        'preload-editor-toolbar'
             const root =
                    )
                 $('#mw-content-text');
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    )
            );


             this.root.append(
             if (!root.length) {
                 loader
                 return;
             );
             }


             this.status =
             this.root =
                 $('<div>')
                 $('<div>')
                    .attr(
                        'id',
                        'preload-editor'
                    )
                     .addClass(
                     .addClass(
                         'preload-editor-status'
                         'preload-editor'
                     );
                     );


             this.root.append(
             root.append(this.root);
                this.status
 
             );
             this.renderShell();


             this.workspace =
             this.restoreDraft();
                $('<div>')
                    .addClass(
                        'preload-editor-workspace'
                    );


             this.root.append(
             window.addEventListener(
                 this.workspace
                'beforeunload',
                () => {
                    if (this.state.dirty) {
                        this.saveDraft();
                    }
                 }
             );
             );
            this.renderWelcome();
         },
         },


         renderWelcome() {
         renderShell() {
             this.workspace.empty();
             this.destroyEditor();
            this.root.empty();


             const section =
             const loader =
                 this.createSection(
                 this.createSection('Preload');
                    'Get started'
                );


             $('<p>')
             const titleInput =
                 .text(
                 this.textInput(
                     'Load an existing preload, create a new one, or start from a sample wiki page.'
                    this.state.preloadTitle,
                 )
                     'Template:Example/Preload'
                .appendTo(
                 ).attr(
                     section
                     'id',
                    'preload-editor-title-input'
                 );
                 );


             const tips =
             titleInput.on(
                 $('<ul>')
                 'input.preloadEditor',
                     .addClass(
                () => {
                        'preload-editor-tips'
                     this.state.preloadTitle =
                    );
                        String(
                            titleInput.val() || ''
                        ).trim();


            [
                    if (
                'Existing preload: loads the preload and its config.json if available.',
                        this.state.dirty &&
                'New preload: starts with the standard Preload Template structure.',
                        this.state.mode === 'new'
                'Sample page: loads normal wiki page content so you can turn it into a preload.'
                    ) {
            ].forEach(
                         this.saveDraft();
                text => {
                    }
                    $('<li>')
                        .text(text)
                         .appendTo(
                            tips
                        );
                 }
                 }
             );
             );


             section.append(
             const loadButton =
                tips
                this.createButton(
            );
                    'Load',
 
                    'preload-editor-button-primary',
            this.workspace.append(
                    () =>
                 section
                        this.loadExisting(
            );
                            titleInput.val()
        },
                        )
                 );


        setStatus(
            const newButton =
            message,
                this.createButton(
            type
                    'New',
        ) {
                    '',
            if (!this.status) {
                    () => this.startNew()
                 return;
                 );
            }


             this.status.removeClass(
             const sampleButton =
                'is-error',
                this.createButton(
                'is-success',
                    'Load Sample Page',
                'is-warning',
                    '',
                'is-loading'
                    () => this.loadSamplePage()
            );
 
            if (type) {
                this.status.addClass(
                    'is-' + type
                 );
                 );
            }


             this.status.text(
             loader.append(
                message || ''
                $('<div>')
             );
                    .addClass(
        },
                        'preload-editor-toolbar'
                    )
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    )
             );


        // Load / create
            this.root.append(loader);


        async loadExisting(
             this.status =
             title
                 $('<div>')
        ) {
                     .addClass(
            title =
                        'preload-editor-status'
                 String(title || '')
                    );
                     .trim();


             if (!title) {
             this.root.append(this.status);
                mw.notify(
                    'Enter a preload page title first.',
                    {
                        type: 'warn'
                    }
                );


                 return;
            this.workspace =
            }
                 $('<div>')
                    .addClass(
                        'preload-editor-workspace'
                    );


             this.setStatus(
             this.root.append(this.workspace);
                'Loading preload...',
                'loading'
            );


             try {
             this.renderWelcome();
                const page =
        },
                    await this.getPage(
                        title
                    );


                if (!page) {
        renderWelcome() {
                    const suggestions =
            this.workspace.empty();
                        await this.findSimilarPreloads(
 
                            title
            const section =
                        );
                this.createSection('Get started');


                    this.renderMissingPage(
            $('<p>')
                        title,
                .text(
                        suggestions
                    'Load an existing preload, create a new one, or start from a sample wiki page.'
                    );
                )
                .appendTo(section);


                     this.setStatus(
            const tips =
                         'Preload not found.',
                $('<ul>')
                        'warning'
                     .addClass(
                         'preload-editor-tips'
                     );
                     );


                     return;
            [
                'Existing preload: loads the preload and its config.json if available.',
                'New preload: starts with the standard Preload Template structure.',
                'Sample page: loads normal wiki page content so you can turn it into a preload.'
            ].forEach(
                text => {
                     $('<li>')
                        .text(text)
                        .appendTo(tips);
                 }
                 }
            );


                await this.loadDefinition(
            section.append(tips);
                    page.title,
            this.workspace.append(section);
                    page.content
        },
                );


                this.setStatus(
        setStatus(
                    'Preload loaded.',
            message,
                    'success'
            type
                 );
        ) {
             } catch (
            if (!this.status) {
                error
                 return;
             ) {
             }
                 console.error(
 
                    'Preload Editor:',
             this.status.removeClass(
                    error
                 'is-error',
                 );
                'is-success',
                'is-warning',
                 'is-loading'
            );


                 this.setStatus(
            if (type) {
                    error.message ||
                 this.status.addClass(
                        'Could not load preload.',
                     'is-' + type
                     'error'
                 );
                 );
             }
             }
            this.status.text(
                message || ''
            );
         },
         },


         async findSimilarPreloads(
         // Load / create
            title
        ) {
            let query =
                title
                    .replace(
                        /\/Preload$/i,
                        ''
                    )
                    .trim();


            if (!query) {
        async loadExisting(title) {
                 query = title;
            title =
            }
                 String(title || '').trim();


             try {
             if (!title) {
                 const results =
                 mw.notify(
                    await this.searchPages(
                     'Enter a preload page title first.',
                        query
                     {
                     );
                         type: 'warn'
 
                     }
                return results.filter(
                     result =>
                         /\/Preload$/i.test(
                            result.title
                        )
                );
            } catch (
                error
            ) {
                console.error(
                    'Preload Editor search:',
                     error
                 );
                 );


                 return [];
                 return;
             }
             }
        },


        renderMissingPage(
            this.setStatus(
            title,
                'Loading preload...',
            suggestions
                'loading'
        ) {
             );
             this.workspace.empty();


             const section =
             try {
                this.createSection(
                const page =
                    'Preload not found'
                    await this.getPage(title);
                );


            $('<p>')
                if (!page) {
                .text(
                     const suggestions =
                    'No preload exists at "' +
                        await this.findSimilarPreloads(
                    title +
                            title
                     '".'
                        );
                )
                .appendTo(
                    section
                );


            if (
                    this.renderMissingPage(
                suggestions.length
                        title,
            ) {
                        suggestions
                $('<h3>')
                    );
                     .text(
 
                         'Similar preloads'
                     this.setStatus(
                    )
                         'Preload not found.',
                    .appendTo(
                         'warning'
                         section
                     );
                     );


                const list =
                     return;
                     $('<div>')
                }
                        .addClass(
                            'preload-editor-suggestions'
                        );


                 suggestions.forEach(
                 await this.loadDefinition(
                     result => {
                     page.title,
                        list.append(
                    page.content
                            this.createButton(
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                    }
                 );
                 );


                 section.append(
                 this.setStatus(
                     list
                     'Preload loaded.',
                    'success'
                );
            } catch (error) {
                console.error(
                    'Preload Editor:',
                    error
                 );
                 );
            }


            section.append(
                 this.setStatus(
                 this.createButton(
                     error.message ||
                     'Create this preload',
                        'Could not load preload.',
                     'preload-editor-button-primary',
                     'error'
                    () =>
                 );
                        this.startNew(
             }
                            title
                        )
                 )
            );
 
             this.workspace.append(
                section
            );
         },
         },


         async startNew(
         async findSimilarPreloads(title) {
            title
             let query =
        ) {
                 title
             title =
                    .replace(
                 String(title || '')
                        /\/Preload$/i,
                        ''
                    )
                     .trim();
                     .trim();


             this.state = {
             if (!query) {
                 preloadTitle: title,
                 query = title;
                preloadSource: '',
            }
                preloadContent:
                    this.defaults.preload,


                 config:
            try {
                     this.emptyConfig(),
                 const results =
                     await this.searchPages(query);


                 variables: ['PAGE'],
                 return results.filter(
                 conditions: [],
                    result =>
                 selectedVariable: 'PAGE',
                        /\/Preload$/i.test(
                mode: 'new',
                            result.title
                 dirty: true
                        )
            };
                 );
            } catch (error) {
                 console.error(
                    'Preload Editor search:',
                    error
                 );


             this.saveDraft();
                return [];
             }
        },


             this.renderEditor();
        renderMissingPage(
            title,
            suggestions
        ) {
             this.workspace.empty();


            this.setStatus(
             const section =
                'Creating a new preload.',
                 this.createSection(
                'success'
                     'Preload not found'
            );
        },
 
        async loadSamplePage() {
             const result =
                 await this.createPromptDialog(
                     'Load Sample Page',
                    'Enter the title of a normal wiki page to use as the starting point.'
                 );
                 );


             if (!result) {
             $('<p>')
                 return;
                 .text(
            }
                    'No preload exists at "' +
                    title +
                    '".'
                )
                .appendTo(section);


             this.setStatus(
             if (suggestions.length) {
                 'Loading sample page...',
                 $('<h3>')
                'loading'
                    .text('Similar preloads')
            );
                    .appendTo(section);


            try {
                 const list =
                 const page =
                     $('<div>')
                     await this.getPage(
                        .addClass(
                         result
                            'preload-editor-suggestions'
                    );
                         );


                 if (!page) {
                 suggestions.forEach(
                    throw new Error(
                    result => {
                        'Sample page not found: ' +
                        list.append(
                        result
                            this.createButton(
                    );
                                result.title,
                 }
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                    }
                 );


                 const sample =
                 section.append(list);
                    this.convertSampleToPreload(
            }
                        page.content
                    );


                 this.state = {
            section.append(
                     preloadTitle: '',
                 this.createButton(
                     preloadSource: '',
                     'Create this preload',
                     preloadContent:
                     'preload-editor-button-primary',
                         sample,
                     () =>
                         this.startNew(title)
                )
            );


                    config:
            this.workspace.append(section);
                        this.emptyConfig(),
        },
 
        async startNew(title) {
            title =
                String(title || '').trim();


                    variables: ['PAGE'],
            this.state = {
                    conditions: [],
                preloadTitle: title,
                    selectedVariable: 'PAGE',
                preloadSource: '',
                    mode: 'new',
                preloadContent:
                     dirty: true
                     this.defaults.preload,
                };


                 this.reanalyze();
                 config:
                    this.emptyConfig(),


                 this.saveDraft();
                 variables: ['PAGE'],
                conditions: [],
                selectedVariable: 'PAGE',
                deletedVariables: [],
                mode: 'new',
                dirty: true
            };


                this.renderEditor();
            this.saveDraft();
            this.renderEditor();


                this.setStatus(
            this.setStatus(
                    'Sample page loaded.',
                'Creating a new preload.',
                    'success'
                'success'
                );
            );
            } catch (
        },
                error
            ) {
                console.error(
                    'Preload Editor sample:',
                    error
                );


                 this.setStatus(
        async loadSamplePage() {
                     error.message ||
            const result =
                        'Could not load sample page.',
                 await this.createPromptDialog(
                     'error'
                     'Load Sample Page',
                     'Enter the title of a normal wiki page to use as the starting point.'
                 );
                 );
            if (!result) {
                return;
             }
             }
        },


        convertSampleToPreload(
            this.setStatus(
            source
                'Loading sample page...',
        ) {
                 'loading'
            let content =
            );
                 String(source || '')
                    .trim();


             const include =
             try {
                content.match(
                const page =
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                    await this.getPage(result);
                );


            if (include) {
                if (!page) {
                content =
                    throw new Error(
                     include[1].trim();
                        'Sample page not found: ' +
            }
                        result
                     );
                }


            content =
                const sample =
                content
                     this.convertSampleToPreload(
                     .replace(
                         page.content
                         /<noinclude>[\s\S]*?<\/noinclude>/gi,
                     );
                        ''
                    )
                     .trim();


            return (
                 this.state = {
                 '<includeonly>\n' +
                    preloadTitle: '',
                content +
                    preloadSource: '',
                '\n</includeonly>\n' +
                    preloadContent: sample,
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
            );
        },


        createPromptDialog(
                     config:
            title,
                         this.emptyConfig(),
            message
        ) {
            return new Promise(
                resolve => {
                     const overlay =
                         $('<div>')
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );


                     const box =
                     variables: ['PAGE'],
                        $('<div>')
                    conditions: [],
                            .addClass(
                    selectedVariable: 'PAGE',
                                'preload-editor-prompt'
                    deletedVariables: [],
                            );
                    mode: 'new',
                    dirty: true
                };


                    $('<h2>')
                this.reanalyze();
                        .text(title)
                this.saveDraft();
                        .appendTo(
                this.renderEditor();
                            box
                        );


                     $('<p>')
                this.setStatus(
                        .text(message)
                    'Sample page loaded.',
                         .appendTo(
                     'success'
                            box
                );
                        );
            } catch (error) {
                console.error(
                    'Preload Editor sample:',
                    error
                );
 
                this.setStatus(
                    error.message ||
                         'Could not load sample page.',
                    'error'
                );
            }
        },


                    const input =
        convertSampleToPreload(source) {
                        this.textInput();
            let content =
                String(source || '').trim();


                    const buttons =
            const include =
                        $('<div>')
                content.match(
                            .addClass(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                                'preload-editor-prompt-buttons'
                );
                            );


                    const cancel =
            if (include) {
                        this.createButton(
                content =
                            'Cancel',
                    include[1].trim();
                            '',
            }
                            () => {
                                overlay.remove();
                                resolve(null);
                            }
                        );


                    const confirm =
            content =
                        this.createButton(
                content
                            'Load',
                    .replace(
                            'preload-editor-button-primary',
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                            () => {
                        ''
                                const value =
                    )
                                    String(
                    .trim();
                                        input.val() ||
                                        ''
                                    ).trim();


                                if (!value) {
            return (
                                    return;
                '<includeonly>\n' +
                                }
                content +
                '\n</includeonly>\n' +
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
            );
        },


                                overlay.remove();
        async loadDefinition(
                                resolve(value);
            title,
                            }
            source
                        );
        ) {
            const content =
                this.extractIncludeonly(source);


                    buttons.append(
            let config =
                        cancel,
                this.emptyConfig();
                        confirm
                    );


                    box.append(
             const configTitle =
                        input,
                 title.replace(
                        buttons
                     /\/+$/,
                    );
                     ''
 
                    overlay.append(
                        box
                    );
 
                    $(document.body)
                        .append(
                            overlay
                        );
 
                    input.trigger(
                        'focus'
                    );
 
                    input.on(
                        'keydown.preloadEditor',
                        event => {
                            if (
                                event.key ===
                                'Enter'
                            ) {
                                confirm.trigger(
                                    'click'
                                );
                            }
 
                            if (
                                event.key ===
                                'Escape'
                            ) {
                                cancel.trigger(
                                    'click'
                                );
                            }
                        }
                    );
                }
            );
        },
 
        async loadDefinition(
            title,
            source
        ) {
            const content =
                this.extractIncludeonly(
                    source
                );
 
            let config =
                this.emptyConfig();
 
             const configTitle =
                 title.replace(
                     /\/+$/,
                     ''
                 ) +
                 ) +
                 '/config.json';
                 '/config.json';


             const configPage =
             const configPage =
                 await this.getPage(
                 await this.getPage(configTitle);
                    configTitle
                );


             if (configPage) {
             if (configPage) {
Line 3,026: Line 2,885:
                             configPage.content
                             configPage.content
                         );
                         );
                 } catch (
                 } catch (error) {
                    error
                ) {
                     throw new Error(
                     throw new Error(
                         'The existing config.json contains invalid JSON: ' +
                         'The existing config.json contains invalid JSON: ' +
Line 3,037: Line 2,894:


             const analysis =
             const analysis =
                 this.analyzePreload(
                 this.analyzePreload(content);
                    content
 
                );
            this.state.deletedVariables = [];


             config =
             config =
Line 3,060: Line 2,917:


                 selectedVariable:
                 selectedVariable:
                     analysis.variables.includes(
                     analysis.variables.includes('PAGE')
                        'PAGE'
                    )
                         ? 'PAGE'
                         ? 'PAGE'
                         : (
                         : (
Line 3,068: Line 2,923:
                             'PAGE'
                             'PAGE'
                         ),
                         ),
                deletedVariables: [],


                 mode: 'edit',
                 mode: 'edit',
Line 3,073: Line 2,930:
             };
             };


            /*
            * Loading the current server version means
            * that this is now the canonical working copy.
            *
            * Any old local draft for this exact preload
            * has either already been restored or was
            * explicitly declined.
            */
             this.clearDraft(title);
             this.clearDraft(title);
             this.renderEditor();
             this.renderEditor();
         },
         },
Line 3,127: Line 2,975:
                     'New preload'
                     'New preload'
                 )
                 )
                 .appendTo(
                 .appendTo(meta);
                    meta
                );


             if (
             if (
                 this.state.mode ===
                 this.state.mode === 'new'
                'new'
             ) {
             ) {
                 $('<span>')
                 $('<span>')
Line 3,140: Line 2,985:
                     )
                     )
                     .text('NEW')
                     .text('NEW')
                     .appendTo(
                     .appendTo(meta);
                        meta
                    );
             }
             }


             editorSection.append(
             editorSection.append(meta);
                meta
            );
 
             editorSection.append(
             editorSection.append(
                 this.renderEditorTools()
                 this.renderEditorTools()
Line 3,170: Line 3,010:
                         'preload-editor-codemirror-mount'
                         'preload-editor-codemirror-mount'
                     )
                     )
                     .append(
                     .append(textarea);
                        textarea
                    );


             editorSection.append(
             editorSection.append(editorMount);
                editorMount
             left.append(editorSection);
            );
 
             left.append(
                editorSection
            );
 
            /*
            * RIGHT PANEL
            *
            * Analysis
            * Variables
            * Variable configuration
            * Generated configuration
            * Actions
            */


             const analysisSection =
             const analysisSection =
                 this.createSection(
                 this.createSection('Analysis');
                    'Analysis'
                );


             const analysisContainer =
             const analysisContainer =
Line 3,208: Line 3,029:


             const variablesSection =
             const variablesSection =
                 this.createSection(
                 this.createSection('Variables');
                    'Variables'
                );


            /*
            * Explicit variable detection button.
            */
             const detectVariablesButton =
             const detectVariablesButton =
                 this.createButton(
                 this.createButton(
Line 3,232: Line 3,048:
                     );
                     );


             const detectControls =
             variablesSection.append(
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass(
Line 3,240: Line 3,056:
                         detectVariablesButton,
                         detectVariablesButton,
                         detectHelp
                         detectHelp
                     );
                     )
 
            variablesSection.append(
                detectControls
             );
             );


Line 3,321: Line 3,134:
             );
             );


             this.workspace.append(
             this.workspace.append(layout);
                layout
            );


             this.reanalyze();
             this.reanalyze();
Line 3,363: Line 3,174:
         },
         },


        /*
        * Explicitly re-read the CodeMirror document
        * and refresh every part of the right panel.
        */
         detectVariables() {
         detectVariables() {
             const source =
             const source =
Line 3,374: Line 3,181:
                 source;
                 source;


             this.state.dirty =
             this.state.dirty = true;
                true;


             this.reanalyze();
             this.reanalyze();


             this.renderVariablesBySelector();
             this.renderVariablesBySelector();
             this.renderVariableConfigurationBySelector();
             this.renderVariableConfigurationBySelector();
             this.renderAnalysisBySelector();
             this.renderAnalysisBySelector();
             this.renderConfigPreviewBySelector();
             this.renderConfigPreviewBySelector();
            this.updateEditorTools();


             this.updateEditorTools();
             this.saveDraft();


            if (
             this.setStatus(
                this.editorView &&
                this.editor &&
                this.editor.syntaxField
            ) {
                try {
                    this.editorView.dispatch({
                        effects:
                            this.editorView.state
                                .field(
                                    this.editor.syntaxField
                                )
                                ? []
                                : []
                    });
                } catch (
                    error
                ) {
                    console.warn(
                        'Preload Editor syntax refresh:',
                        error
                    );
                }
            }
 
            /*
            * Detection itself changes/synchronizes the
            * working state, so persist the resulting
            * draft immediately.
            */
            this.saveDraft();
 
             this.setStatus(
                 'Variables detected from the current preload.',
                 'Variables detected from the current preload.',
                 'success'
                 'success'
Line 3,432: Line 3,204:


             const content =
             const content =
                 this.extractIncludeonly(
                 this.extractIncludeonly(source);
                    source
                );


             const analysis =
             const analysis =
                 this.analyzePreload(
                 this.analyzePreload(content);
                    content
                );


             this.state.preloadContent =
             this.state.preloadContent =
Line 3,464: Line 3,232:
                     analysis.variables.filter(
                     analysis.variables.filter(
                         name =>
                         name =>
                             name !==
                             name !== 'PAGE'
                            'PAGE'
                     )
                     )
                 );
                 );
Line 3,480: Line 3,247:
         },
         },


         renderAnalysis(
         renderAnalysis(container) {
            container
        ) {
             container.empty();
             container.empty();


Line 3,559: Line 3,324:
             );
             );


             container.append(
             container.append(summary);
                summary
            );


             if (
             if (
                 analysis.conditionVariables
                 analysis.conditionVariables.length
                    .length
             ) {
             ) {
                 container.append(
                 container.append(
Line 3,574: Line 3,336:
                         .text(
                         .text(
                             'Condition variables: ' +
                             'Condition variables: ' +
                             analysis
                             analysis.conditionVariables.join(', ')
                                .conditionVariables
                                .join(', ')
                         )
                         )
                 );
                 );
Line 3,587: Line 3,347:
                 );
                 );


             if (
             if (validation.errors.length) {
                validation.errors.length
            ) {
                 const errors =
                 const errors =
                     $('<ul>')
                     $('<ul>')
Line 3,600: Line 3,358:
                         $('<li>')
                         $('<li>')
                             .text(error)
                             .text(error)
                             .appendTo(
                             .appendTo(errors);
                                errors
                            );
                     }
                     }
                 );
                 );


                 container.append(
                 container.append(errors);
                    errors
                );
             }
             }


             if (
             if (validation.warnings.length) {
                validation.warnings.length
            ) {
                 const warnings =
                 const warnings =
                     $('<ul>')
                     $('<ul>')
Line 3,624: Line 3,376:
                         $('<li>')
                         $('<li>')
                             .text(warning)
                             .text(warning)
                             .appendTo(
                             .appendTo(warnings);
                                warnings
                            );
                     }
                     }
                 );
                 );


                 container.append(
                 container.append(warnings);
                    warnings
                );
             }
             }


Line 3,662: Line 3,410:
                 container.length
                 container.length
             ) {
             ) {
                 this.renderAnalysis(
                 this.renderAnalysis(container);
                    container
                );
             }
             }
         },
         },


         /*
         // Variables
        * Variable list
        */


         renderVariables(
         renderVariables(container) {
            container
        ) {
             container.empty();
             container.empty();


Line 3,695: Line 3,437:
                     if (
                     if (
                         name !== 'PAGE' &&
                         name !== 'PAGE' &&
                         !ordered.includes(
                         !ordered.includes(name)
                            name
                        )
                     ) {
                     ) {
                         ordered.push(
                         ordered.push(name);
                            name
                        );
                     }
                     }
                 }
                 }
             );
             );


            /*
            * Preserve configured-but-unused fields
            * in the compact list.
            */
             const configuredFields =
             const configuredFields =
                 this.state.config &&
                 this.state.config &&
Line 3,725: Line 3,459:
                     if (
                     if (
                         upper !== 'PAGE' &&
                         upper !== 'PAGE' &&
                         !ordered.includes(
                         !ordered.includes(upper)
                            upper
                        )
                     ) {
                     ) {
                         ordered.push(
                         ordered.push(upper);
                            upper
                    }
                         );
                }
            );
 
            const deleted =
                Array.isArray(
                    this.state.deletedVariables
                )
                    ? this.state.deletedVariables
                    : [];
 
            deleted.forEach(
                name => {
                    const upper =
                         String(name).toUpperCase();
 
                    if (
                        upper !== 'PAGE' &&
                        !ordered.includes(upper)
                    ) {
                        ordered.push(upper);
                     }
                     }
                 }
                 }
Line 3,746: Line 3,497:
                     const isSelected =
                     const isSelected =
                         name ===
                         name ===
                         this.state
                         this.state.selectedVariable;
                            .selectedVariable;


                     const isUsed =
                     const isUsed =
                         analysis.variables.includes(
                         analysis.variables.includes(
                             name
                             name
                        );
                    const isConfigured =
                        !!(
                            this.state.config &&
                            this.state.config.fields &&
                            this.state.config.fields[name]
                         );
                         );


Line 3,776: Line 3,533:
                             );
                             );


                     const nameLabel =
                     $('<span>')
                        $('<span>')
                        .addClass(
                            .addClass(
                            'preload-editor-variable-name'
                                'preload-editor-variable-name'
                        )
                            )
                        .text(name)
                            .text(name);
                        .appendTo(item);


                    item.append(
                     if (name === 'PAGE') {
                        nameLabel
                    );
 
                     if (
                        name ===
                        'PAGE'
                    ) {
                         item.append(
                         item.append(
                             $('<span>')
                             $('<span>')
                                 .addClass(
                                 .addClass(
                                     'preload-editor-badge'
                                     'preload-editor-badge'
                                )
                                .text('AUTOMATIC')
                        );
                    } else if (!isConfigured) {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge is-warning'
                                 )
                                 )
                                 .text(
                                 .text(
                                     'AUTOMATIC'
                                     isUsed
                                        ? 'NOT CONFIGURED'
                                        : 'DELETED'
                                 )
                                 )
                         );
                         );
                     } else if (
                     } else if (!isUsed) {
                        !isUsed
                    ) {
                         item.append(
                         item.append(
                             $('<span>')
                             $('<span>')
Line 3,808: Line 3,566:
                                     'preload-editor-badge is-warning'
                                     'preload-editor-badge is-warning'
                                 )
                                 )
                                 .text(
                                 .text('UNUSED')
                                    'UNUSED'
                                )
                         );
                         );
                     }
                     }


                     list.append(
                     list.append(item);
                        item
                    );
                 }
                 }
             );
             );


             container.append(
             container.append(list);
                list
            );


             if (
             if (!ordered.length) {
                !ordered.length
            ) {
                 container.append(
                 container.append(
                     $('<div>')
                     $('<div>')
Line 3,850: Line 3,600:
                 container.length
                 container.length
             ) {
             ) {
                 this.renderVariables(
                 this.renderVariables(container);
                    container
                );
             }
             }
         },
         },


        /*
         selectVariable(name) {
        * Select a variable and display only
        * its configuration.
        *
        * No scrolling.
        */
         selectVariable(
            name
        ) {
             name =
             name =
                 String(name || '')
                 String(name || '')
Line 3,879: Line 3,619:
                         this.analysis &&
                         this.analysis &&
                         this.analysis.variables
                         this.analysis.variables
                             ? this.analysis
                             ? this.analysis.variables
                                .variables
                             : []
                             : []
                     ).filter(
                     ).filter(
                         variable =>
                         variable =>
                             variable !==
                             variable !== 'PAGE'
                            'PAGE'
                     )
                     )
                 );
                 );


             if (
             const configuredUnused =
                !available.includes(
                this.state.config &&
                     name
                this.state.config.fields
                 )
                    ? Object.keys(
             ) {
                        this.state.config.fields
                    )
                        .map(
                            variable =>
                                variable.toUpperCase()
                        )
                    : [];
 
            configuredUnused.forEach(
                variable => {
                    if (
                        !available.includes(variable) &&
                        variable !== 'PAGE'
                     ) {
                        available.push(variable);
                    }
                 }
            );
 
             if (!available.includes(name)) {
                 return;
                 return;
             }
             }
Line 3,901: Line 3,658:


             this.renderVariablesBySelector();
             this.renderVariablesBySelector();
             this.renderVariableConfigurationBySelector();
             this.renderVariableConfigurationBySelector();
         },
         },
Line 3,922: Line 3,678:
         },
         },


         renderVariableConfiguration(
         renderVariableConfiguration(container) {
            container
        ) {
             container.empty();
             container.empty();


Line 3,946: Line 3,700:


             const field =
             const field =
                 fields[name] ||
                 fields[name];
                this.createDefaultField(
                    name,
                    analysis
                );


             if (
             if (name === 'PAGE') {
                name ===
                'PAGE'
            ) {
                 container.append(
                 container.append(
                     this.createAutomaticPageCard(
                     this.createAutomaticPageCard(
                         field
                         field ||
                        this.getAutomaticPageField()
                     )
                     )
                 );
                 );
Line 3,965: Line 3,713:
             }
             }


             container.append(
             if (!field) {
                 this.createVariableCard(
                 const missing =
                     name,
                     $('<div>')
                    field,
                        .addClass(
                    analysis
                            'preload-editor-variable preload-editor-variable-missing'
                )
                        );
            );
        },


        /*
                $('<div>')
        * PAGE is automatic/precompiled.
                    .addClass(
        */
                        'preload-editor-variable-header'
 
                    )
        createAutomaticPageCard(
                    .append(
             field
                        $('<h3>')
         ) {
                            .text(name),
 
                        $('<span>')
                            .addClass(
                                'preload-editor-badge is-warning'
                            )
                            .text(
                                'NOT CONFIGURED'
                            )
                    )
                    .appendTo(missing);
 
                $('<p>')
                    .addClass(
                        'preload-editor-analysis-note'
                    )
                    .text(
                        'This variable is currently not present in config.json.'
                    )
                    .appendTo(missing);
 
                container.append(missing);
                return;
            }
 
             container.append(
                this.createVariableCard(
                    name,
                    field,
                    analysis
                )
            );
         },
 
        createAutomaticPageCard(field) {
             const card =
             const card =
                 $('<div>')
                 $('<div>')
Line 3,998: Line 3,778:


             header.append(
             header.append(
                 $('<h3>')
                 $('<h3>').text('PAGE'),
                    .text('PAGE'),


                 $('<span>')
                 $('<span>')
Line 4,005: Line 3,784:
                         'preload-editor-badge'
                         'preload-editor-badge'
                     )
                     )
                     .text(
                     .text('AUTOMATIC')
                        'AUTOMATIC'
                    )
             );
             );


             card.append(
             card.append(header);
                header
            );


             $('<p>')
             $('<p>')
Line 4,021: Line 3,796:
                     'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
                     'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
                 )
                 )
                 .appendTo(
                 .appendTo(card);
                    card
                );


             const label =
             const label =
Line 4,049: Line 3,822:
             const type =
             const type =
                 this.textInput(
                 this.textInput(
                     field.type ||
                     field.type || 'page'
                    'page'
                 ).prop(
                 ).prop(
                     'disabled',
                     'disabled',
Line 4,058: Line 3,830:
             const required =
             const required =
                 this.checkbox(
                 this.checkbox(
                     field.required !==
                     field.required !== false
                        false
                 ).prop(
                 ).prop(
                     'disabled',
                     'disabled',
Line 4,067: Line 3,838:
             const namespace =
             const namespace =
                 this.textInput(
                 this.textInput(
                     field.namespace ||
                     field.namespace || 'Main'
                    'Main'
                 ).prop(
                 ).prop(
                     'disabled',
                     'disabled',
Line 4,076: Line 3,846:
             const allowNew =
             const allowNew =
                 this.checkbox(
                 this.checkbox(
                     field['allow-new'] ===
                     field['allow-new'] === true
                        true
                 ).prop(
                 ).prop(
                     'disabled',
                     'disabled',
Line 4,140: Line 3,909:


             header.append(
             header.append(
                 $('<h3>')
                 $('<h3>').text(name)
                    .text(name)
             );
             );


             if (
             if (
                 !analysis.variables.includes(
                 !analysis.variables.includes(name)
                    name
                )
             ) {
             ) {
                 header.append(
                 header.append(
Line 4,154: Line 3,920:
                             'preload-editor-badge is-warning'
                             'preload-editor-badge is-warning'
                         )
                         )
                         .text(
                         .text('UNUSED')
                            'UNUSED'
                        )
                 );
                 );
             }
             }


             card.append(
             card.append(header);
                header
            );


             const label =
             const label =
Line 4,176: Line 3,938:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,190: Line 3,951:
             const description =
             const description =
                 this.textarea(
                 this.textarea(
                     field.description ||
                     field.description || ''
                    ''
                 ).addClass(
                 ).addClass(
                     'preload-editor-small-textarea'
                     'preload-editor-small-textarea'
Line 4,203: Line 3,963:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,216: Line 3,975:


             const types = [
             const types = [
                 [
                 ['text', 'Text'],
                    'text',
                 ['textarea', 'Textarea'],
                    'Text'
                 ['number', 'Number'],
                ],
                 ['boolean', 'Boolean'],
                 [
                 ['select', 'Select'],
                    'textarea',
                 ['multiselect', 'Multi-select'],
                    'Textarea'
                 ['page', 'Page'],
                ],
                 ['user', 'User'],
                 [
                 ['date', 'Date']
                    'number',
                    'Number'
                ],
                 [
                    'boolean',
                    'Boolean'
                ],
                 [
                    'select',
                    'Select'
                ],
                 [
                    'multiselect',
                    'Multi-select'
                ],
                 [
                    'page',
                    'Page'
                ],
                 [
                    'user',
                    'User'
                ],
                 [
                    'date',
                    'Date'
                ]
             ];
             ];


Line 4,257: Line 3,989:
                 this.select(
                 this.select(
                     types.map(
                     types.map(
                         (
                         ([value, label]) => ({
                            [
                                value,
                                label
                            ]
                        ) => ({
                             value,
                             value,
                             label
                             label
                         })
                         })
                     ),
                     ),
                     field.type ||
                     field.type || 'text'
                        'text'
                 );
                 );


Line 4,280: Line 4,006:


                     this.renderVariableConfigurationBySelector();
                     this.renderVariableConfigurationBySelector();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,294: Line 4,019:
             const required =
             const required =
                 this.checkbox(
                 this.checkbox(
                     field.required ===
                     field.required === true
                        true
                 );
                 );


Line 4,302: Line 4,026:
                 () => {
                 () => {
                     field.required =
                     field.required =
                         required.prop(
                         required.prop('checked');
                            'checked'
                        );


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,321: Line 4,042:
             const placeholder =
             const placeholder =
                 this.textInput(
                 this.textInput(
                     field.placeholder ||
                     field.placeholder || ''
                    ''
                 );
                 );


Line 4,339: Line 4,059:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,353: Line 4,072:
             const defaultInput =
             const defaultInput =
                 this.textInput(
                 this.textInput(
                     field.default ===
                     field.default === undefined
                        undefined
                         ? ''
                         ? ''
                         : field.default
                         : field.default
Line 4,373: Line 4,091:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,387: Line 4,104:


             if (
             if (
                 field.type ===
                 field.type === 'textarea'
                'textarea'
             ) {
             ) {
                 const maxRows =
                 const maxRows =
                     this.textInput(
                     this.textInput(
                         field.maxRows ||
                         field.maxRows || ''
                        ''
                     ).attr(
                     ).attr(
                         'type',
                         'type',
Line 4,409: Line 4,124:


                         if (
                         if (
                             Number.isFinite(
                             Number.isFinite(value) &&
                                value
                            ) &&
                             value > 0
                             value > 0
                         ) {
                         ) {
Line 4,421: Line 4,134:


                         this.markDirty();
                         this.markDirty();
                         this.renderConfigPreviewBySelector();
                         this.renderConfigPreviewBySelector();
                     }
                     }
Line 4,435: Line 4,147:


             if (
             if (
                 field.type ===
                 field.type === 'select' ||
                    'select' ||
                 field.type === 'multiselect'
                 field.type ===
                    'multiselect'
             ) {
             ) {
                 this.renderSelectConfiguration(
                 this.renderSelectConfiguration(
Line 4,446: Line 4,156:
             }
             }


             return card;
             /*
        },
            * DELETE VARIABLE
            *
            * This removes the field from the generated
            * config.json, but does not remove %%VARIABLE%%
            * from the preload itself.
            */
            const deleteButton =
                this.createButton(
                    'Delete variable',
                    'preload-editor-button-danger preload-editor-delete-variable',
                    async () => {
                        const confirmed =
                            await this.createConfirmDialog(
                                'Delete variable',
                                'Delete "' +
                                name +
                                '" from the configuration?\n\nThe variable will remain in the preload, but its config.json field will be removed.',
                                'Delete variable',
                                'Cancel'
                            );
 
                        if (!confirmed) {
                            return;
                        }


        renderSelectConfiguration(
                        this.deleteVariable(name);
            card,
                     }
            field
        ) {
            const source =
                this.textInput(
                     field.source || '',
                    'category:Example'
                 );
                 );


             source.on(
             card.append(
                 'input.preloadEditor',
                 $('<div>')
                () => {
                    .addClass(
                     const value =
                        'preload-editor-variable-delete'
                        source.val();
                    )
                     .append(deleteButton)
            );


                    if (value) {
            return card;
                        field.source =
        },
                            value;
 
                    } else {
        async deleteVariable(name) {
                        delete field.source;
            name =
                    }
                String(name || '')
                    .trim()
                    .toUpperCase();
 
            if (
                !name ||
                name === 'PAGE'
            ) {
                return;
            }


                    this.markDirty();
            if (
                !this.state.config ||
                !this.state.config.fields
            ) {
                return;
            }


                    this.renderConfigPreviewBySelector();
            delete this.state.config.fields[name];
                }
            );


             card.append(
             if (
                 this.fieldRow(
                 !Array.isArray(
                     'Source',
                     this.state.deletedVariables
                    source,
                    'Currently supported source format: category:Category Name'
                 )
                 )
             );
             ) {
                this.state.deletedVariables = [];
            }


             const sourcePrefix =
             if (
                 this.textInput(
                 !this.state.deletedVariables.includes(
                     field['source-prefix'] ||
                     name
                     ''
                )
            ) {
                this.state.deletedVariables.push(
                     name
                 );
                 );
            }


             sourcePrefix.on(
             this.state.selectedVariable =
                 'input.preloadEditor',
                name;
 
            this.markDirty();
 
            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
            this.renderAnalysisBySelector();
            this.renderConfigPreviewBySelector();
 
            this.setStatus(
                'Variable "' +
                name +
                '" removed from the configuration.',
                'warning'
            );
        },
 
        renderSelectConfiguration(
            card,
            field
        ) {
            const source =
                this.textInput(
                    field.source || '',
                    'category:Example'
                );
 
            source.on(
                 'input.preloadEditor',
                 () => {
                 () => {
                     const value =
                     const value =
                         sourcePrefix.val();
                         source.val();


                     if (value) {
                     if (value) {
                         field[
                         field.source = value;
                            'source-prefix'
                        ] = value;
                     } else {
                     } else {
                         delete field[
                         delete field.source;
                            'source-prefix'
                        ];
                     }
                     }


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,516: Line 4,284:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Source prefix',
                     'Source',
                     sourcePrefix,
                     source,
                     'Only source values beginning with this text are included.'
                     'Currently supported source format: category:Category Name'
                 )
                 )
             );
             );


             const excludeCategory =
             const sourcePrefix =
                 this.textInput(
                 this.textInput(
                     field.exclude &&
                     field['source-prefix'] || ''
                    field.exclude.category ||
                    ''
                 );
                 );


             excludeCategory.on(
             sourcePrefix.on(
                 'input.preloadEditor',
                 'input.preloadEditor',
                 () => {
                 () => {
                     const value =
                     const value =
                         excludeCategory.val();
                        sourcePrefix.val();
 
                    if (value) {
                        field['source-prefix'] =
                            value;
                    } else {
                        delete field['source-prefix'];
                    }
 
                    this.markDirty();
                    this.renderConfigPreviewBySelector();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Source prefix',
                    sourcePrefix,
                    'Only source values beginning with this text are included.'
                )
            );
 
            const excludeCategory =
                this.textInput(
                    field.exclude &&
                    field.exclude.category ||
                    ''
                );
 
            excludeCategory.on(
                'input.preloadEditor',
                () => {
                    const value =
                         excludeCategory.val();


                     if (value) {
                     if (value) {
Line 4,539: Line 4,338:
                             Object.assign(
                             Object.assign(
                                 {},
                                 {},
                                 field.exclude ||
                                 field.exclude || {},
                                    {},
                                 {
                                 {
                                     category:
                                     category: value
                                        value
                                 }
                                 }
                             );
                             );
Line 4,549: Line 4,346:
                         field.exclude
                         field.exclude
                     ) {
                     ) {
                         delete field.exclude
                         delete field.exclude.category;
                            .category;


                         if (
                         if (
Line 4,562: Line 4,358:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,589: Line 4,384:


             const optionsValue =
             const optionsValue =
                 Array.isArray(
                 Array.isArray(field.options)
                    field.options
                )
                     ? field.options
                     ? field.options
                         .map(
                         .map(
Line 4,604: Line 4,397:
                                 return (
                                 return (
                                     String(
                                     String(
                                         option.value ||
                                         option.value || ''
                                        ''
                                     ) +
                                     ) +
                                     ' | ' +
                                     ' | ' +
Line 4,637: Line 4,429:
                                     line.trim()
                                     line.trim()
                             )
                             )
                             .filter(
                             .filter(Boolean);
                                Boolean
                            );


                     if (!lines.length) {
                     if (!lines.length) {
Line 4,665: Line 4,455:
                                         label:
                                         label:
                                             parts
                                             parts
                                                 .slice(
                                                 .slice(1)
                                                    1
                                                )
                                                 .join(
                                                 .join(
                                                     ' | '
                                                     ' | '
Line 4,677: Line 4,465:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 }
                 }
Line 4,704: Line 4,491:
             $('<h4>')
             $('<h4>')
                 .text(title)
                 .text(title)
                 .appendTo(
                 .appendTo(wrapper);
                    wrapper
                );


             const existing =
             const existing =
                 field[property] &&
                 field[property] &&
                 typeof field[property] ===
                 typeof field[property] === 'object'
                    'object'
                     ? field[property]
                     ? field[property]
                     : {};
                     : {};
Line 4,741: Line 4,525:
                         }
                         }
                     ],
                     ],
                     existing.transform ||
                     existing.transform || ''
                        ''
                 );
                 );


             const prefix =
             const prefix =
                 this.textInput(
                 this.textInput(
                     existing.prefix ||
                     existing.prefix || ''
                    ''
                 );
                 );


             const suffix =
             const suffix =
                 this.textInput(
                 this.textInput(
                     existing.suffix ||
                     existing.suffix || ''
                    ''
                 );
                 );


             const from =
             const from =
                 this.textInput(
                 this.textInput(
                     existing.from ||
                     existing.from || ''
                    ''
                 );
                 );


             const to =
             const to =
                 this.textInput(
                 this.textInput(
                     existing.to ||
                     existing.to || ''
                    ''
                 );
                 );


Line 4,805: Line 4,584:


                     prefixRow.toggle(
                     prefixRow.toggle(
                         type ===
                         type === 'remove-prefix'
                            'remove-prefix'
                     );
                     );


                     suffixRow.toggle(
                     suffixRow.toggle(
                         type ===
                         type === 'remove-suffix'
                            'remove-suffix'
                     );
                     );


                     fromRow.toggle(
                     fromRow.toggle(
                         type ===
                         type === 'replace'
                            'replace'
                     );
                     );


                     toRow.toggle(
                     toRow.toggle(
                         type ===
                         type === 'replace'
                            'replace'
                     );
                     );
                 };
                 };
Line 4,830: Line 4,605:
                         transform.val();
                         transform.val();


                     let object =
                     let object = null;
                        null;


                     if (
                     if (
                         type ===
                         type === 'remove-prefix' &&
                            'remove-prefix' &&
                         prefix.val()
                         prefix.val()
                     ) {
                     ) {
                         object = {
                         object = {
                             transform:
                             transform: type,
                                type,
                             prefix:
                             prefix:
                                 prefix.val()
                                 prefix.val()
                         };
                         };
                     } else if (
                     } else if (
                         type ===
                         type === 'remove-suffix' &&
                            'remove-suffix' &&
                         suffix.val()
                         suffix.val()
                     ) {
                     ) {
                         object = {
                         object = {
                             transform:
                             transform: type,
                                type,
                             suffix:
                             suffix:
                                 suffix.val()
                                 suffix.val()
                         };
                         };
                     } else if (
                     } else if (
                         type ===
                         type === 'replace' &&
                            'replace' &&
                         from.val()
                         from.val()
                     ) {
                     ) {
                         object = {
                         object = {
                             transform:
                             transform: type,
                                type,
                             from:
                             from:
                                 from.val(),
                                 from.val(),
Line 4,871: Line 4,639:


                     if (object) {
                     if (object) {
                         field[property] =
                         field[property] = object;
                            object;
                     } else {
                     } else {
                         delete field[property];
                         delete field[property];
Line 4,878: Line 4,645:


                     this.markDirty();
                     this.markDirty();
                     this.renderConfigPreviewBySelector();
                     this.renderConfigPreviewBySelector();
                 };
                 };
Line 4,918: Line 4,684:
             renderInputs();
             renderInputs();


             wrapper.append(
             wrapper.append(controls);
                controls
            );


             return wrapper;
             return wrapper;
Line 4,938: Line 4,702:
                 config.fields || {};
                 config.fields || {};


             const orderedFields =
             const orderedFields = {};
                {};


            /*
            * PAGE is always first and automatic.
            */
             orderedFields.PAGE =
             orderedFields.PAGE =
                 this.getAutomaticPageField();
                 this.getAutomaticPageField();


             Object.keys(
             Object.keys(fields)
                fields
            )
                 .filter(
                 .filter(
                     name =>
                     name =>
                         name.toUpperCase() !==
                         name.toUpperCase() !== 'PAGE'
                        'PAGE'
                 )
                 )
                 .forEach(
                 .forEach(
Line 4,976: Line 4,733:
         },
         },


         renderConfigPreview(
         renderConfigPreview(container) {
            container
        ) {
             if (container) {
             if (container) {
                 container.text(
                 container.text(
Line 5,006: Line 4,761:


         markDirty() {
         markDirty() {
             this.state.dirty =
             this.state.dirty = true;
                true;
 
            /*
            * Any configuration change is also a
            * local draft change.
            */
             this.saveDraft();
             this.saveDraft();
         },
         },
Line 5,021: Line 4,770:
             let title =
             let title =
                 String(
                 String(
                     this.state.preloadTitle ||
                     this.state.preloadTitle || ''
                    ''
                 ).trim();
                 ).trim();


Line 5,037: Line 4,785:


                 title = result;
                 title = result;
                 this.state.preloadTitle =
                 this.state.preloadTitle =
                     title;
                     title;
             }
             }


            /*
            * Always pull the actual CodeMirror
            * document before saving.
            */
             this.state.preloadContent =
             this.state.preloadContent =
                 this.getEditorContent();
                 this.getEditorContent();
Line 5,052: Line 4,795:
                 this.validateBeforeSave();
                 this.validateBeforeSave();


             if (
             if (validation.errors.length) {
                validation.errors.length
            ) {
                 this.showValidationErrors(
                 this.showValidationErrors(
                     validation
                     validation
                 );
                 );


                /*
                * Keep the local draft because the
                * save did not complete.
                */
                 this.saveDraft();
                 this.saveDraft();
                 return;
                 return;
             }
             }
Line 5,075: Line 4,811:
             try {
             try {
                 const exists =
                 const exists =
                     await this.pageExists(
                     await this.pageExists(title);
                        title
                    );


                 await this.savePage(
                 await this.savePage(
Line 5,088: Line 4,822:
                 );
                 );


                 this.state.mode =
                 this.state.mode = 'edit';
                    'edit';
                this.state.dirty = false;


                this.state.dirty =
                 this.clearDraft(title);
                    false;
 
                /*
                * The preload has now been explicitly
                * saved, so its local recovery copy is
                * no longer needed.
                */
                 this.clearDraft(
                    title
                );


                 this.setStatus(
                 this.setStatus(
Line 5,111: Line 4,835:
                     'Preload saved.',
                     'Preload saved.',
                     {
                     {
                         type:
                         type: 'success'
                            'success'
                     }
                     }
                 );
                 );
             } catch (
             } catch (error) {
                error
            ) {
                 console.error(
                 console.error(
                     'Preload Editor save:',
                     'Preload Editor save:',
Line 5,123: Line 4,844:
                 );
                 );


                /*
                * Keep the local draft intact if
                * the wiki save failed.
                */
                 this.saveDraft();
                 this.saveDraft();


Line 5,139: Line 4,856:
                         'Could not save preload.',
                         'Could not save preload.',
                     {
                     {
                         type:
                         type: 'error'
                            'error'
                     }
                     }
                 );
                 );
Line 5,149: Line 4,865:
             let title =
             let title =
                 String(
                 String(
                     this.state.preloadTitle ||
                     this.state.preloadTitle || ''
                    ''
                 ).trim();
                 ).trim();


Line 5,165: Line 4,880:


                 title = result;
                 title = result;
                 this.state.preloadTitle =
                 this.state.preloadTitle =
                     title;
                     title;
             }
             }


            /*
            * Always use the actual CodeMirror
            * document when saving.
            */
             this.state.preloadContent =
             this.state.preloadContent =
                 this.getEditorContent();
                 this.getEditorContent();


            /*
            * Make sure the configuration is
            * synchronized with anything newly
            * typed into the preload.
            */
             this.reanalyze();
             this.reanalyze();


Line 5,187: Line 4,892:
                 this.validateBeforeSave();
                 this.validateBeforeSave();


             if (
             if (validation.errors.length) {
                validation.errors.length
            ) {
                 this.showValidationErrors(
                 this.showValidationErrors(
                     validation
                     validation
                 );
                 );


                /*
                * Keep the recovery copy.
                */
                 this.saveDraft();
                 this.saveDraft();
                 return;
                 return;
             }
             }
Line 5,216: Line 4,915:
             try {
             try {
                 const preloadExists =
                 const preloadExists =
                     await this.pageExists(
                     await this.pageExists(title);
                        title
                    );


                 await this.savePage(
                 await this.savePage(
Line 5,235: Line 4,932:


                 await this.savePage(
                 await this.savePage(
                     configTitle,
                     configTitle,
                     this.getConfigJSON(),
                     this.getConfigJSON(),
                     configExists
                     configExists
                         ? 'Updated preload config with Preload Editor'
                         ? 'Updated preload config with Preload Editor'
                         : 'Created preload config with Preload Editor',
                         : 'Created preload config with Preload Editor',
                     !configExists
                     !configExists
                 );
                 );
 
 
                 this.state.mode =
                 this.state.mode = 'edit';
                    'edit';
                 this.state.dirty = false;
 
                 this.state.preloadTitle = title;
                 this.state.dirty =
 
                    false;
                 this.clearDraft(title);
 
 
                 this.state.preloadTitle =
                 this.setStatus(
                    title;
                     'Preload and config saved successfully.',
 
                     'success'
                /*
                 );
                * Both the preload and config are now
 
                * safely on the wiki.
                 mw.notify(
                */
                     'Preload and config saved.',
                 this.clearDraft(
                     {
                    title
                         type: 'success'
                );
                     }
 
                 );
                 this.setStatus(
             } catch (error) {
                     'Preload and config saved successfully.',
                     'success'
                 );
 
                 mw.notify(
                     'Preload and config saved.',
                     {
                         type:
                            'success'
                     }
                 );
             } catch (
                error
            ) {
                 console.error(
                 console.error(
                     'Preload Editor save:',
                     'Preload Editor save:',
Line 5,280: Line 4,963:
                 );
                 );


                /*
                * IMPORTANT:
                * if either wiki save fails, retain the
                * browser draft so nothing is lost.
                */
                 this.saveDraft();
                 this.saveDraft();


Line 5,297: Line 4,975:
                         'Could not save preload/config.',
                         'Could not save preload/config.',
                     {
                     {
                         type:
                         type: 'error'
                            'error'
                     }
                     }
                 );
                 );
Line 5,310: Line 4,987:
             const title =
             const title =
                 String(
                 String(
                     this.state.preloadTitle ||
                     this.state.preloadTitle || ''
                    ''
                 ).trim();
                 ).trim();


Line 5,322: Line 4,998:
             if (
             if (
                 title &&
                 title &&
                 !/\/Preload$/i.test(
                 !/\/Preload$/i.test(title)
                    title
                )
             ) {
             ) {
                 warnings.push(
                 warnings.push(
Line 5,344: Line 5,018:


             if (
             if (
                 !/<includeonly>/i.test(
                 !/<includeonly>/i.test(content)
                    content
                )
             ) {
             ) {
                 warnings.push(
                 warnings.push(
Line 5,354: Line 5,026:


             if (
             if (
                 !/<noinclude>/i.test(
                 !/<noinclude>/i.test(content)
                    content
                )
             ) {
             ) {
                 warnings.push(
                 warnings.push(
Line 5,370: Line 5,040:
                 );
                 );


            /*
            * Synchronize configuration one final
            * time before saving.
            */
             this.state.config =
             this.state.config =
                 this.prepareConfigForVariables(
                 this.prepareConfigForVariables(
Line 5,400: Line 5,066:
                 condition => {
                 condition => {
                     if (
                     if (
                         condition.type ===
                         condition.type === 'if'
                        'if'
                     ) {
                     ) {
                         depth++;
                         depth++;
                     } else if (
                     } else if (
                         condition.type ===
                         condition.type === 'endif'
                        'endif'
                     ) {
                     ) {
                         depth--;
                         depth--;


                         if (
                         if (depth < 0) {
                            depth < 0
                        ) {
                             errors.push(
                             errors.push(
                                 'Found @endif without a matching @if.'
                                 'Found @endif without a matching @if.'
Line 5,420: Line 5,082:
                         }
                         }
                     } else if (
                     } else if (
                         condition.type ===
                         condition.type === 'elseif' ||
                            'elseif' ||
                         condition.type === 'else'
                         condition.type ===
                            'else'
                     ) {
                     ) {
                         if (
                         if (depth === 0) {
                            depth === 0
                        ) {
                             errors.push(
                             errors.push(
                                 '@' +
                                 '@' +
Line 5,452: Line 5,110:
         },
         },


         showValidationErrors(
         showValidationErrors(validation) {
            validation
        ) {
             this.renderAnalysisBySelector();
             this.renderAnalysisBySelector();


            /*
            * Keep the local recovery copy because
            * validation prevented the wiki save.
            */
             this.saveDraft();
             this.saveDraft();


             this.setStatus(
             this.setStatus(
                 validation.errors.join(
                 validation.errors.join(' '),
                    ' '
                ),
                 'error'
                 'error'
             );
             );


             mw.notify(
             mw.notify(
                 validation.errors.join(
                 validation.errors.join('\n'),
                    '\n'
                ),
                 {
                 {
                     type:
                     type: 'error'
                        'error'
                 }
                 }
             );
             );
Line 5,487: Line 5,134:
     $(function () {
     $(function () {
         if (
         if (
             mw.config.get(
             mw.config.get('wgPageName') !==
                'wgPageName'
            ) !==
             'Petit_Planet:Preload_Editor'
             'Petit_Planet:Preload_Editor'
         ) {
         ) {