Jump to content

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

From The Petit Planet Wiki
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
 
(22 intermediate revisions by the same user not shown)
Line 1: Line 1:
(function (mw, $) {
(function (mw, $) {
     'use strict';
     'use strict';


     const PreloadEditor = {
     const PreloadEditor = {
         version: '0.2.3',
         version: '1.2.0',


         state: {
         state: {
Line 13: Line 12:
             variables: [],
             variables: [],
             conditions: [],
             conditions: [],
            selectedVariable: 'PAGE',
            deletedVariables: [],
             mode: 'idle',
             mode: 'idle',
             dirty: false
             dirty: false,
            dirtyPreload: false,
            dirtyConfig: false,
 
            // Local draft state
            localDraftSavedAt: null,
            localDraftAvailable: false
         },
         },


Line 23: Line 30:
         workspace: null,
         workspace: null,
         status: null,
         status: null,
        localDraftStatus: null,
         editorTools: null,
         editorTools: null,
        draftStorageKey: 'PetitPlanet.PreloadEditor.drafts.v1',


         defaults: {
         defaults: {
             preload:
             preload: '<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
                '<includeonly>\n' +
                '<!-- preload here -->\n' +
                '</includeonly>\n' +
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
         },
         },


         // API
         // =========================================================
        // Browser-local draft storage
        // =========================================================
 
        getDraftStorage() {
            try {
                const raw = window.localStorage.getItem(this.draftStorageKey);


        async getPage(title) {
                if (!raw) {
            const api = new mw.Api();
                    return {
                        active: null,
                        drafts: {}
                    };
                }


            const response =
                const parsed = JSON.parse(raw);
                await api.get({
                    action: 'query',
                    prop: 'revisions',
                    titles: title,
                    rvprop: 'content',
                    rvslots: 'main',
                    formatversion: 2
                });


            const page =
                if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                response &&
                    return {
                response.query &&
                        active: null,
                response.query.pages &&
                        drafts: {}
                 response.query.pages[0];
                    };
                 }


            if (!page || page.missing) {
                if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
                return null;
                    parsed.drafts = {};
            }
                }


            const content =
                 return parsed;
                 page.revisions &&
            } catch (error) {
                page.revisions[0] &&
                 console.warn('Preload Editor: Could not read local drafts.', error);
                 page.revisions[0].slots &&
                page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;


            return {
                return {
                title: page.title || title,
                    active: null,
                content:
                    drafts: {}
                    typeof content === 'string'
                };
                        ? content
             }
                        : ''
             };
         },
         },


         async pageExists(title) {
         setDraftStorage(storage) {
             return !!await this.getPage(title);
             try {
                window.localStorage.setItem(this.draftStorageKey, JSON.stringify(storage));
                return true;
            } catch (error) {
                console.warn('Preload Editor: Could not save local draft.', error);
                return false;
            }
         },
         },


         async searchPages(query) {
         getDraftId(title) {
             const api = new mw.Api();
             title = String(title || '').trim();
            return title ? title.toLowerCase() : '__untitled__';
        },


             const response =
        getDraftSnapshot() {
                 await api.get({
            let content = this.getEditorContent();
                     action: 'query',
 
                    list: 'search',
            if (!this.editorView && !content) {
                    srsearch: query,
                content = this.state.preloadContent || '';
                    srlimit: 10,
            }
                     srnamespace: 10,
 
                    formatversion: 2
             let config = null;
                });
 
            try {
                 config = this.state.config
                    ? JSON.parse(JSON.stringify(this.state.config))
                     : this.emptyConfig();
            } catch (error) {
                console.warn('Preload Editor: Could not clone config for local draft.', error);
                config = this.emptyConfig();
            }
 
            return {
                version: 3,
                preloadTitle: String(this.state.preloadTitle || ''),
                preloadSource: String(this.state.preloadSource || ''),
                preloadContent: String(content || ''),
                config,
 
                variables: Array.isArray(this.state.variables)
                    ? this.state.variables.slice()
                     : [],
 
                conditions: Array.isArray(this.state.conditions)
                    ? this.state.conditions.slice()
                    : [],


            return (
                selectedVariable: String(this.state.selectedVariable || 'PAGE'),
                response &&
                response.query &&
                response.query.search
                    ? response.query.search
                    : []
            );
        },


        async savePage(
                deletedVariables: Array.isArray(this.state.deletedVariables)
            title,
                    ? this.state.deletedVariables.slice()
            content,
                    : [],
            summary,
            createOnly
        ) {
            const api = new mw.Api();


            const params = {
                 mode: this.state.mode || 'edit',
                 action: 'edit',
                 dirty: !!this.state.dirty,
                 title: title,
                 dirtyPreload: !!this.state.dirtyPreload,
                 text: content,
                 dirtyConfig: !!this.state.dirtyConfig,
                 summary:
                 savedAt: Date.now()
                    summary ||
                    'Updated with Preload Editor',
                 formatversion: 2
             };
             };
        },


             if (createOnly) {
        updateLocalDraftStatus(message, type) {
                 params.createonly = true;
             if (!this.localDraftStatus) {
                 return;
             }
             }


             return api.postWithToken(
             this.localDraftStatus.removeClass(
                 'csrf',
                 'is-saved',
                 params
                 'is-warning',
                'is-error',
                'is-empty'
             );
             );
        },


        // Preload parsing
            if (type) {
 
                 this.localDraftStatus.addClass('is-' + type);
        extractIncludeonly(source) {
            }
            const match =
                 String(source || '').match(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                );


             return match
             this.localDraftStatus.text(message || '');
                ? match[1].trim()
                : String(source || '').trim();
         },
         },


         findVariables(content) {
         formatLocalDraftTime(timestamp) {
             const variables = [];
             if (!timestamp) {
             const pattern =
                return '';
                 /%%([A-Za-z0-9_-]+)%%/g;
             }
 
            try {
                 return new Date(timestamp).toLocaleTimeString([], {
                    hour: '2-digit',
                    minute: '2-digit',
                    second: '2-digit'
                });
            } catch (error) {
                return '';
            }
        },
 
        updateLocalDraftIndicator() {
            if (!this.localDraftStatus) {
                return;
            }


             let match;
             if (this.state.localDraftSavedAt) {
                const time = this.formatLocalDraftTime(this.state.localDraftSavedAt);


            while (
                 this.updateLocalDraftStatus(
                 (match = pattern.exec(content)) !== null
                    time ? 'Saved locally at ' + time : 'Saved locally',
            ) {
                    'saved'
                 const name =
                 );
                    match[1].toUpperCase();


                 if (!variables.includes(name)) {
                 return;
                    variables.push(name);
                }
             }
             }


             return variables;
             this.updateLocalDraftStatus('No local draft', 'empty');
         },
         },


         findConditions(content) {
         saveDraft() {
             const conditions = [];
            if (!this.state.dirty) {
             const pattern =
                return false;
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
            }
 
             const snapshot = this.getDraftSnapshot();
             const id = this.getDraftId(snapshot.preloadTitle);
            const storage = this.getDraftStorage();
 
            storage.drafts[id] = snapshot;
            storage.active = id;


             let match;
             const saved = this.setDraftStorage(storage);


             while (
             if (saved) {
                 (match = pattern.exec(content)) !== null
                 this.state.localDraftSavedAt = snapshot.savedAt;
             ) {
                this.state.localDraftAvailable = true;
                 conditions.push({
                this.updateLocalDraftIndicator();
                    type:
             } else {
                        match[1].toLowerCase(),
                 this.state.localDraftAvailable = false;
                    expression:
                this.updateLocalDraftStatus('Could not save local draft', 'error');
                        match[2].trim(),
                    index:
                        match.index
                });
             }
             }


             return conditions;
             return saved;
         },
         },


         findConditionVariables(content) {
         clearDraft(title) {
             const variables = [];
             const storage = this.getDraftStorage();
             const conditions =
             const id = this.getDraftId(title);
                this.findConditions(content);


             conditions.forEach(condition => {
             if (storage.drafts && storage.drafts[id]) {
                if (
                delete storage.drafts[id];
                    condition.type !== 'if' &&
            }
                    condition.type !== 'elseif'
                ) {
                    return;
                }


                const matches =
            if (storage.active === id) {
                    condition.expression.match(
                storage.active = null;
                        /[A-Za-z0-9_-]+/g
            }
                    ) || [];


                matches.forEach(name => {
            this.setDraftStorage(storage);
                    const upper =
                        name.toUpperCase();


                    if (
            this.state.localDraftSavedAt = null;
                        !variables.includes(upper)
             this.state.localDraftAvailable = false;
                    ) {
                        variables.push(upper);
                    }
                });
             });


             return variables;
             this.updateLocalDraftIndicator();
         },
         },


         analyzePreload(content) {
         getMostRecentDraft() {
             const variables =
             const storage = this.getDraftStorage();
                 this.findVariables(content);
 
            if (!storage.drafts || !Object.keys(storage.drafts).length) {
                return null;
            }
 
            if (storage.active && storage.drafts[storage.active]) {
                return {
                    id: storage.active,
                    draft: storage.drafts[storage.active]
                 };
            }
 
            let latestId = null;
            let latestDraft = null;
 
            Object.entries(storage.drafts).forEach(([id, draft]) => {
                if (!draft || typeof draft !== 'object') {
                    return;
                }


            const conditions =
                if (
                 this.findConditions(content);
                    !latestDraft ||
                    (Number(draft.savedAt) || 0) > (Number(latestDraft.savedAt) || 0)
                ) {
                    latestId = id;
                    latestDraft = draft;
                 }
            });


             const conditionVariables =
             if (!latestDraft) {
                this.findConditionVariables(
                 return null;
                    content
            }
                 );


             return {
             return {
                 variables,
                 id: latestId,
                 conditions,
                 draft: latestDraft
                conditionVariables
             };
             };
         },
         },


         // Config
         async restoreDraft() {
            const result = this.getMostRecentDraft();
 
            if (!result || !result.draft) {
                return false;
            }
 
            const draft = result.draft;
            const title = String(draft.preloadTitle || '').trim();
            const displayTitle = title || 'an untitled preload';
 
            const savedAt = draft.savedAt
                ? new Date(draft.savedAt).toLocaleString()
                : '';
 
            const message =
                'Preload Editor found unsaved local work for ' +
                displayTitle +
                (savedAt ? '\n\nLast saved locally: ' + savedAt : '') +
                '\n\nRestore it?';


        emptyConfig() {
            const restore = await this.createConfirmDialog(
            return {
                 'Restore unsaved work',
                 settings: {
                 message,
                    incomplete: 'add-stub'
                 'Restore',
                 },
                'Discard'
                 fields: {
             );
                    PAGE: {
                        label: 'Page Name',
                        description:
                            'Name of the page.',
                        type: 'page',
                        required: true,
                        namespace: 'Main',
                        'allow-new': true
                    }
                }
             };
        },


        normalizeConfig(config) {
             if (!restore) {
             if (
                 this.clearDraft(title);
                !config ||
                return false;
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                 config = this.emptyConfig();
             }
             }
            let config = this.emptyConfig();


             if (
             if (
                 !config.settings ||
                 draft.config &&
                 typeof config.settings !== 'object' ||
                 typeof draft.config === 'object' &&
                 Array.isArray(config.settings)
                 !Array.isArray(draft.config)
             ) {
             ) {
                 config.settings = {};
                 try {
                    config = this.normalizeConfig(
                        JSON.parse(JSON.stringify(draft.config))
                    );
                } catch (error) {
                    console.warn(
                        'Preload Editor: Could not restore local config.',
                        error
                    );
                }
             }
             }


             if (
             const hasDirtyFlags =
                !config.fields ||
                 typeof draft.dirtyPreload === 'boolean' ||
                 typeof config.fields !== 'object' ||
                 typeof draft.dirtyConfig === 'boolean';
                 Array.isArray(config.fields)
            ) {
                config.fields = {};
            }


             if (!config.fields.PAGE) {
             this.state = {
                 config.fields.PAGE = {
                 preloadTitle: title,
                    label: 'Page Name',
                preloadSource: String(draft.preloadSource || ''),
                    description:
                preloadContent: String(draft.preloadContent || ''),
                        'Name of the page.',
                config,
                    type: 'page',
 
                    required: true,
                variables: Array.isArray(draft.variables)
                     namespace: 'Main',
                     ? draft.variables.slice()
                     'allow-new': true
                     : [],
                };
            }


            return config;
                conditions: Array.isArray(draft.conditions)
        },
                    ? draft.conditions.slice()
                    : [],


        createDefaultField(name, analysis) {
                selectedVariable: String(draft.selectedVariable || 'PAGE'),
            const isConditionVariable =
                analysis.conditionVariables.includes(
                    name
                );


            return {
                 deletedVariables: Array.isArray(draft.deletedVariables)
                 label:
                     ? draft.deletedVariables
                     this.humanizeName(name),
                        .map(name => String(name).toUpperCase())
                description: '',
                        .filter(name => name !== 'PAGE')
                type:
                     : [],
                     isConditionVariable
                        ? 'boolean'
                        : 'text'
            };
        },


        humanizeName(name) {
                mode: draft.mode || 'edit',
            return String(name || '')
                .replace(/[_-]+/g, ' ')
                .replace(
                    /([a-z])([A-Z])/g,
                    '$1 $2'
                )
                .replace(/\s+/g, ' ')
                .trim()
                .replace(
                    /\b\w/g,
                    char => char.toUpperCase()
                );
        },


        prepareConfigForVariables(
                dirty: hasDirtyFlags
            config,
                    ? !!(draft.dirtyPreload || draft.dirtyConfig)
            analysis
                    : true,
        ) {
            config =
                this.normalizeConfig(config);


            const existing =
                dirtyPreload: hasDirtyFlags
                config.fields || {};
                    ? !!draft.dirtyPreload
                    : true,


            const fields = {};
                dirtyConfig: hasDirtyFlags
                    ? !!draft.dirtyConfig
                    : true,


            fields.PAGE =
                 localDraftSavedAt: Number(draft.savedAt) || null,
                 existing.PAGE || {
                localDraftAvailable: true
                    label: 'Page Name',
            };
                    description:
                        'Name of the page.',
                    type: 'page',
                    required: true,
                    namespace: 'Main',
                    'allow-new': true
                };


             analysis.variables.forEach(name => {
             this.reanalyze();
                if (name === 'PAGE') {
            this.renderEditor();
                    return;
            this.updateLocalDraftIndicator();
                }


                fields[name] =
            this.setStatus(
                    existing[name] ||
                'Restored unsaved local work from this browser.',
                    this.createDefaultField(
                'warning'
                        name,
            );
                        analysis
 
                    );
            mw.notify('Unsaved Preload Editor work restored.', {
                type: 'success'
             });
             });


             Object.keys(existing).forEach(
             return true;
                name => {
        },
                    const upper =
                        name.toUpperCase();


                    if (!fields[upper]) {
        // =========================================================
                        fields[upper] =
        // API
                            existing[name];
        // =========================================================
                    }
                }
            );


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


        validateConfig(config, analysis) {
             const page =
             const errors = [];
                response &&
            const warnings = [];
                response.query &&
                response.query.pages &&
                response.query.pages[0];


             if (
             if (!page || page.missing) {
                !config ||
                 return null;
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                 return {
                    valid: false,
                    errors: [
                        'Config must be an object.'
                    ],
                    warnings: []
                };
             }
             }


             if (
             const content =
                 !config.fields ||
                page.revisions &&
                 typeof config.fields !== 'object' ||
                 page.revisions[0] &&
                 Array.isArray(config.fields)
                 page.revisions[0].slots &&
             ) {
                 page.revisions[0].slots.main &&
                 errors.push(
                page.revisions[0].slots.main.content;
                    '"fields" must be an object.'
 
                );
             return {
                 title: page.title || title,
                content: typeof content === 'string' ? content : '',
                contentmodel: page.contentmodel || 'wikitext'
            };
        },


                return {
        async pageExists(title) {
                    valid: false,
            return !!await this.getPage(title);
                    errors,
        },
                    warnings
                };
            }


             const configured =
        async searchPages(query) {
                 Object.keys(
             const response = await new mw.Api().get({
                    config.fields
                action: 'query',
                 ).map(
                 list: 'search',
                    name => name.toUpperCase()
                srsearch: query,
                 );
                 srlimit: 10,
                srnamespace: 10,
                 formatversion: 2
            });


             analysis.variables.forEach(
             return (
                name => {
                response &&
                    if (
                response.query &&
                        !configured.includes(name)
                response.query.search
                    ) {
            ) || [];
                        errors.push(
        },
                            'Variable "' +
 
                            name +
        /*
                            '" is not configured.'
        * Save a page.
                        );
        *
                    }
        * contentModel is mainly used when creating a page.
                 }
        *
             );
        * For an existing page whose content model needs
        * changing, use changeContentModel() first.
        */
        async savePage(title, content, summary, createOnly, contentModel) {
            const params = {
                action: 'edit',
                title,
                text: content,
                summary: summary || 'Updated with Preload Editor',
                formatversion: 2
            };
 
            if (createOnly) {
                params.createonly = true;
            }
 
            if (contentModel) {
                 params.contentmodel = contentModel;
            }
 
             return new mw.Api().postWithToken('csrf', params);
        },


            configured.forEach(name => {
        /*
                if (
        * Change the content model of an existing page.
                    !analysis.variables.includes(
        *
                        name
        * This is the API equivalent of using
                    )
        * Special:ChangeContentModel manually.
                ) {
        *
                    warnings.push(
        * The page must already exist.
                        'Field "' +
        */
                        name +
        async changeContentModel(title, model, summary) {
                        '" is not used by the preload.'
            return new mw.Api().postWithToken('csrf', {
                    );
                action: 'changecontentmodel',
                 }
                title,
                model,
                 reason: summary || 'Changed content model with Preload Editor'
             });
             });
        },


            if (!configured.includes('PAGE')) {
        /*
                 errors.push(
        * Save config.json and make sure its content model
                     'PAGE must always be configured.'
        * is JSON.
        *
        * New page:
        *  create directly with contentmodel=json.
        *
        * Existing page:
        *  if not already JSON, change its model first,
        *  then save the JSON normally.
        */
        async saveConfigPage(title, content, summary) {
            const page = await this.getPage(title);
 
            if (!page) {
                 return this.savePage(
                    title,
                    content,
                    summary,
                    true,
                     'json'
                 );
                 );
             }
             }


             Object.entries(
             if (String(page.contentmodel || '').toLowerCase() !== 'json') {
                config.fields
                await this.changeContentModel(
            ).forEach(
                     title,
                ([name, field]) => {
                    'json',
                    if (
                    'Changed preload config content model to JSON'
                        !field ||
                );
                        typeof field !== 'object' ||
            }
                        Array.isArray(field)
                     ) {
                        errors.push(
                            'Field "' +
                            name +
                            '" must be an object.'
                        );
                        return;
                    }


                    if (!field.type) {
            return this.savePage(
                        warnings.push(
                title,
                            'Field "' +
                content,
                            name +
                summary,
                            '" has no type.'
                 false
                        );
                    }
 
                    if (
                        (
                            field.type === 'select' ||
                            field.type === 'multiselect'
                        ) &&
                        !field.source &&
                        !Array.isArray(
                            field.options
                        )
                    ) {
                        warnings.push(
                            'Field "' +
                            name +
                            '" is a ' +
                            field.type +
                            ' without a source or manual options.'
                        );
                    }
                 }
             );
             );
        },


            return {
        // =========================================================
                valid:
        // Config import helpers
                    errors.length === 0,
         // =========================================================
                errors,
                warnings
            };
         },


         // Transformation helpers
         /*
        * Accept either:
        *
        *  Template:Example/Preload
        *
        * or:
        *
        *  Template:Example/Preload/config.json
        *
        * and always return the config.json title.
        */
        normalizeConfigTitle(title) {
            title = String(title || '').trim();
            title = title.replace(/\/+$/, '');


        transformationObject(
             if (/\/config\.json$/i.test(title)) {
            transform,
                 return title;
            prefix,
            suffix,
            from,
            to
        ) {
             if (!transform) {
                 return null;
             }
             }


             switch (transform) {
             return title + '/config.json';
                 case 'remove-prefix':
        },
                    return prefix
 
                        ? {
        /*
                            transform:
        * Return a safe clone of a config field.
                                'remove-prefix',
        *
                            prefix
        * This is deliberately JSON-based because config
                        }
        * definitions are JSON-compatible objects.
                        : null;
        */
        cloneConfigField(field) {
            try {
                 return JSON.parse(JSON.stringify(field));
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not clone imported field.',
                    error
                );


                 case 'remove-suffix':
                 return null;
                    return suffix
            }
                        ? {
        },
                            transform:
                                'remove-suffix',
                            suffix
                        }
                        : null;


                case 'replace':
        /*
                    return from === ''
        * Normalize imported field names to the same uppercase
                        ? null
        * convention used throughout the editor.
                        : {
        */
                            transform:
        getImportableFields(sourceConfig) {
                                'replace',
            const fields = sourceConfig && sourceConfig.fields;
                            from,
                            to: to || ''
                        };


                 default:
            if (
                    return null;
                !fields ||
                 typeof fields !== 'object' ||
                Array.isArray(fields)
            ) {
                return {};
             }
             }
        },


        // UI helpers
            const result = {};


        createSection(
            Object.entries(fields).forEach(([name, field]) => {
            title,
                const upper = String(name || '').trim().toUpperCase();
            className
        ) {
            const section =
                $('<section>')
                    .addClass(
                        'preload-editor-section'
                    );


            if (className) {
                if (!upper || upper === 'PAGE') {
                section.addClass(className);
                    return;
            }
                }


            $('<h2>')
                if (
                .addClass(
                    !field ||
                     'preload-editor-section-title'
                     typeof field !== 'object' ||
                )
                    Array.isArray(field)
                .text(title)
                 ) {
                 .appendTo(section);
                    return;
                }


            return section;
                const clone = this.cloneConfigField(field);
        },


        createButton(
                if (!clone) {
            label,
                    return;
            className,
                 }
            callback
        ) {
            const button =
                 $('<button>')
                    .attr(
                        'type',
                        'button'
                    )
                    .addClass(
                        'preload-editor-button'
                    )
                    .text(label);


            if (className) {
                 result[upper] = clone;
                 button.addClass(className);
             });
             }


             if (callback) {
             return result;
                button.on(
        },
                    'click.preloadEditor',
                    callback
                );
            }


             return button;
        /*
        },
        * Show the variable-selection dialog after a source
        * config has been loaded.
        *
        * Resolves:
        *  null -> cancelled
        *  []  -> confirmed with no selections
        *  [...] -> selected field names
        */
        createImportDefinitionsDialog(sourceTitle, fields) {
             return new Promise(resolve => {
                const names = Object.keys(fields).sort((a, b) => a.localeCompare(b));


        fieldRow(
                const overlay = $('<div>')
            label,
            input,
            description
        ) {
            const row =
                $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-field'
                         'preload-editor-prompt-overlay preload-editor-import-overlay'
                     );
                     );


            $('<label>')
                const box = $('<div>')
                .addClass(
                    .addClass(
                    'preload-editor-label'
                        'preload-editor-prompt preload-editor-import-dialog'
                 )
                    );
                .text(label)
 
                .appendTo(row);
                 $('<h2>')
                    .text('Import variable definitions')
                    .appendTo(box);


            if (description) {
                 $('<p>')
                 $('<div>')
                     .text(
                     .addClass(
                         'Choose which variable definitions to import from "' +
                         'preload-editor-help'
                        sourceTitle +
                        '". Existing local definitions can be replaced.'
                     )
                     )
                     .text(description)
                     .appendTo(box);
                    .appendTo(row);
 
            }
                if (!names.length) {
                    $('<div>')
                        .addClass(
                            'preload-editor-empty preload-editor-import-empty'
                        )
                        .text(
                            'This config.json does not contain any importable variable definitions.'
                        )
                        .appendTo(box);


            row.append(input);
                    const emptyButtons = $('<div>')
            return row;
                        .addClass('preload-editor-prompt-buttons');
        },


        textInput(
                    const close = this.createButton(
            value,
                        'Close',
            placeholder
                        'preload-editor-button-primary',
        ) {
                         () => {
            return $('<input>')
                             overlay.remove();
                .attr({
                             resolve(null);
                    type: 'text',
                         }
                    value:
                     );
                         value === undefined ||
                        value === null
                             ? ''
                             : value,
                    placeholder:
                         placeholder || ''
                })
                .addClass(
                     'preload-editor-input'
                );
        },


        textarea(value) {
                    emptyButtons.append(close);
            return $('<textarea>')
                    box.append(emptyButtons);
                .val(
                     overlay.append(box);
                    value === undefined ||
                     value === null
                        ? ''
                        : value
                )
                .addClass(
                    'preload-editor-textarea'
                );
        },


        checkbox(checked) {
                    $(document.body).append(overlay);
            return $('<input>')
                .attr(
                    'type',
                    'checkbox'
                )
                .prop(
                    'checked',
                    !!checked
                )
                .addClass(
                    'preload-editor-checkbox'
                );
        },


        select(options, value) {
                    overlay.attr('tabindex', '-1');
            const select =
                     overlay.trigger('focus');
                $('<select>')
                     .addClass(
                        'preload-editor-select'
                    );


            options.forEach(option => {
                     return;
                $('<option>')
                }
                    .attr(
                        'value',
                        option.value
                    )
                    .text(option.label)
                    .prop(
                        'selected',
                        String(
                            option.value
                        ) ===
                            String(
                                value === undefined
                                    ? ''
                                    : value
                            )
                    )
                     .appendTo(select);
            });


            return select;
                const search = this.textInput('', 'Search variables...')
        },
                    .addClass('preload-editor-import-search');


        // CodeMirror
                const searchWrap = $('<div>')
                    .addClass('preload-editor-import-search-wrap')
                    .append(search);


        destroyEditor() {
                box.append(searchWrap);
            if (!this.editor) {
                this.editorView = null;
                this.editorTools = null;
                return;
            }


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


            if (view && inputHandler) {
                const selectAll = this.createButton(
                view.dom.removeEventListener(
                    'Select all',
                    'input',
                    '',
                     inputHandler
                    () => {
                        list
                            .find('input[type="checkbox"]:visible')
                            .prop('checked', true);
 
                        updateSummary();
                     }
                 );
                 );
            }


            if (view) {
                const deselectAll = this.createButton(
                view.destroy();
                    'Deselect all',
            }
                    '',
                    () => {
                        list
                            .find('input[type="checkbox"]:visible')
                            .prop('checked', false);
 
                        updateSummary();
                    }
                );


            this.editor = null;
                controls.append(selectAll, deselectAll);
            this.editorView = null;
                box.append(controls);
            this.editorTools = null;
        },


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


        getEditorContent() {
                const localFields =
            if (
                    this.state.config && this.state.config.fields
                this.editorView &&
                        ? this.state.config.fields
                this.editorView.state
                        : {};
            ) {
                return this.editorView.state.doc
                    .toString();
            }


            return (
                 names.forEach(name => {
                 this.state.preloadContent || ''
                    const existing = !!localFields[name];
            );
                    const field = fields[name];
        },


        setEditorContent(content) {
                    const type = field && field.type
            content =
                        ? String(field.type)
                String(content || '');
                        : 'text';


            if (!this.editorView) {
                    const label = field && field.label
                this.state.preloadContent =
                        ? String(field.label)
                    content;
                        : this.humanizeName(name);
                return;
            }


            const current =
                    const row = $('<label>')
                this.editorView.state.doc
                        .addClass('preload-editor-import-row')
                    .toString();
                        .attr('data-variable', name)
                        .attr(
                            'data-search',
                            (name + ' ' + label + ' ' + type).toLowerCase()
                        );


            if (current === content) {
                    const checkbox = this.checkbox(false)
                return;
                        .attr('data-import-variable', name)
            }
                        .addClass('preload-editor-import-checkbox');


            this.editorView.dispatch({
                    const content = $('<span>')
                changes: {
                         .addClass('preload-editor-import-content');
                    from: 0,
                    to:
                         this.editorView.state.doc
                            .length,
                    insert: content
                }
            });
        },


        getEditorSelection() {
                    const header = $('<span>')
            if (
                        .addClass('preload-editor-import-header');
                !this.editorView ||
                !this.editorView.state
            ) {
                return null;
            }


            const selection =
                    $('<strong>')
                this.editorView.state
                        .text(name)
                    .selection.main;
                        .appendTo(header);


            return {
                    $('<span>')
                from: selection.from,
                        .addClass('preload-editor-import-type')
                to: selection.to,
                        .text(type)
                text:
                         .appendTo(header);
                    this.editorView.state.doc
                         .sliceString(
                            selection.from,
                            selection.to
                        )
            };
        },


        focusEditor() {
                    const meta = $('<span>')
            if (this.editorView) {
                        .addClass('preload-editor-import-meta');
                this.editorView.focus();
            }
        },


        insertEditorText(
                    $('<span>')
            text,
                        .text(label)
            selectionFrom,
                        .appendTo(meta);
            selectionTo,
            cursorPosition
        ) {
            if (!this.editorView) {
                return;
            }


            const view = this.editorView;
                    const status = $('<span>')
            const selection =
                        .addClass('preload-editor-import-status')
                view.state.selection.main;
                        .toggleClass('is-existing', existing)
                        .toggleClass('is-new', !existing)
                        .text(existing ? 'REPLACE EXISTING' : 'NEW');


            const from =
                    content.append(header, meta, status);
                selectionFrom === undefined
                    row.append(checkbox, content);
                     ? selection.from
                     list.append(row);
                    : selectionFrom;
                });
 
                box.append(list);


            const to =
                const summary = $('<div>')
                selectionTo === undefined
                     .addClass('preload-editor-import-summary');
                     ? selection.to
                    : selectionTo;


            const cursor =
                const updateSummary = () => {
                cursorPosition === undefined
                    const selected = list.find(
                     ? from + text.length
                        'input[type="checkbox"]:checked'
                    : cursorPosition;
                     ).length;


            view.dispatch({
                    const existingSelected = list
                changes: {
                        .find('input[type="checkbox"]:checked')
                    from,
                        .filter(function () {
                    to,
                            const name = $(this).attr(
                    insert: text
                                'data-import-variable'
                },
                            );
                selection: {
                    anchor: cursor
                }
            });


            view.focus();
                            return !!localFields[name];
        },
                        }).length;


        insertVariable(name) {
                    let text =
            name =
                        selected +
                String(name || '')
                        ' ' +
                    .trim()
                        (selected === 1 ? 'variable' : 'variables') +
                    .toUpperCase();
                        ' selected.';


            if (!name) {
                    if (existingSelected) {
                return;
                        text +=
            }
                            ' ' +
                            existingSelected +
                            ' existing ' +
                            (existingSelected === 1
                                ? 'definition'
                                : 'definitions') +
                            ' will be replaced.';
                    }


            const selected =
                    summary.text(text);
                this.getEditorSelection();


            if (!selected) {
                    importButton.prop('disabled', selected === 0);
                 return;
                 };
            }


            const text =
                const filterRows = () => {
                '%%' + name + '%%';
                    const query = String(search.val() || '')
                        .trim()
                        .toLowerCase();


            this.insertEditorText(
                    list.find('.preload-editor-import-row').each(function () {
                text,
                        const row = $(this);
                selected.from,
                        const haystack = String(
                selected.to
                            row.attr('data-search') || ''
            );
                        );
        },


        wrapSelectionWithVariable(name) {
                        row.toggle(!query || haystack.includes(query));
            name =
                     });
                String(name || '')
                };
                    .trim()
                     .toUpperCase();


            if (!name) {
                search.on(
                 return;
                    'input.preloadEditor',
            }
                    filterRows
                 );


            const selected =
                list.on(
                 this.getEditorSelection();
                    'change.preloadEditor',
                    'input[type="checkbox"]',
                    updateSummary
                 );


            if (
                 const buttons = $('<div>')
                !selected ||
                    .addClass('preload-editor-prompt-buttons');
                 selected.from === selected.to
            ) {
                this.insertVariable(name);
                return;
            }


            const text =
                const cancel = this.createButton(
                '%%' +
                    'Cancel',
                name +
                    '',
                 '%%';
                    () => {
                        overlay.remove();
                        resolve(null);
                    }
                 );


            const selectedText =
                const importButton = this.createButton(
                selected.text;
                    'Import selected',
                    'preload-editor-button-primary',
                    () => {
                        const selected = [];


            const replacement =
                        list
                text +
                            .find('input[type="checkbox"]:checked')
                selectedText +
                            .each(function () {
                text;
                                selected.push(
                                    String(
                                        $(this).attr(
                                            'data-import-variable'
                                        )
                                    )
                                );
                            });


            this.insertEditorText(
                        overlay.remove();
                 replacement,
                        resolve(selected);
                 selected.from,
                    }
                 selected.to,
                );
                 selected.from +
 
                    replacement.length
                 buttons.append(cancel, importButton);
            );
                 box.append(summary, buttons);
        },
                 overlay.append(box);
 
                $(document.body).append(overlay);
 
                overlay.attr('tabindex', '-1');
                 overlay.trigger('focus');
 
                updateSummary();
                search.trigger('focus');


        insertCondition(type, variable) {
                const close = event => {
            type =
                    if (event.key === 'Escape') {
                String(type || '')
                        cancel.trigger('click');
                     .toLowerCase();
                     }
                };


            variable =
                 overlay.on(
                 String(variable || '')
                    'keydown.preloadEditor',
                     .trim()
                     close
                    .toUpperCase();
                );


            if (
                overlay.on(
                type === 'if' ||
                    'click.preloadEditor',
                type === 'elseif'
                    event => {
            ) {
                        if (event.target === overlay.get(0)) {
                if (!variable) {
                            cancel.trigger('click');
                     return;
                        }
                }
                     }
            }
                );
            });
        },


             const selected =
        /*
                 this.getEditorSelection();
        * Main import workflow.
        */
        async importVariableDefinitions() {
             const source = await this.createPromptDialog(
                'Import variable definitions',
                 'Enter a preload title or its config.json title.'
            );


             if (!selected) {
             if (!source) {
                 return;
                 return;
             }
             }


             let text = '';
             const configTitle = this.normalizeConfigTitle(source);
            let cursorOffset = 0;


             if (type === 'if') {
             this.setStatus(
                 const opening =
                'Loading source config...',
                    '<!-- @if ' +
                 'loading'
                    variable +
            );
                    ' -->';


                 const closing =
            try {
                    '<!-- @endif -->';
                 const page = await this.getPage(configTitle);


                 if (
                 if (!page) {
                    selected.from !==
                     throw new Error(
                    selected.to
                         'No config.json was found at "' +
                ) {
                         configTitle +
                     text =
                         '".'
                        opening +
                     );
                         '\n' +
                 }
                         selected.text +
 
                         '\n' +
                let sourceConfig;
                        closing;
 
                     cursorOffset =
                        text.length;
                 } else {
                    text =
                        opening +
                        '\n' +
                        '\n' +
                        closing;


                     cursorOffset =
                try {
                         opening.length + 1;
                     sourceConfig = JSON.parse(page.content);
                } catch (error) {
                    throw new Error(
                         'The source config.json contains invalid JSON: ' +
                        error.message
                    );
                 }
                 }
            } else if (type === 'elseif') {
                text =
                    '<!-- @elseif ' +
                    variable +
                    ' -->';


                 cursorOffset =
                 if (
                     text.length;
                     !sourceConfig ||
            } else if (type === 'else') {
                    typeof sourceConfig !== 'object' ||
                 text =
                    Array.isArray(sourceConfig)
                     '<!-- @else -->';
                 ) {
                     throw new Error(
                        'The source config.json must contain a JSON object.'
                    );
                }


                 cursorOffset =
                 if (
                     text.length;
                     !sourceConfig.fields ||
            } else if (type === 'endif') {
                    typeof sourceConfig.fields !== 'object' ||
                 text =
                    Array.isArray(sourceConfig.fields)
                     '<!-- @endif -->';
                 ) {
                     throw new Error(
                        'The source config.json does not contain a valid "fields" object.'
                    );
                }


                 cursorOffset =
                 const fields = this.getImportableFields(sourceConfig);
                    text.length;
            } else {
                return;
            }


            this.insertEditorText(
                const selected =
                text,
                    await this.createImportDefinitionsDialog(
                selected.from,
                        configTitle,
                selected.to,
                        fields
                selected.from +
                     );
                     cursorOffset
            );
        },


        // Preload syntax highlighting
                if (selected === null) {
                    this.setStatus(
                        'Variable import cancelled.',
                        'warning'
                    );


        getKnownVariables() {
                    return;
            const known =
                 }
                 new Set(['PAGE']);


            const fields =
                 if (!selected.length) {
                 this.state.config &&
                    this.setStatus(
                this.state.config.fields;
                        'No variable definitions were imported.',
                        'warning'
                    );
 
                    return;
                }


            if (
                if (!this.state.config) {
                fields &&
                    this.state.config = this.emptyConfig();
                typeof fields === 'object'
                }
            ) {
                Object.keys(fields)
                    .forEach(name => {
                        known.add(
                            String(name)
                                .toUpperCase()
                        );
                    });
            }


            return known;
                if (!this.state.config.fields) {
        },
                    this.state.config.fields = {};
                }


        createSyntaxDecorations(
                const imported = [];
            state,
                const replaced = [];
            Decoration
        ) {
            const decorations = [];
            const source =
                state.doc.toString();


            const knownVariables =
                selected.forEach(name => {
                this.getKnownVariables();
                    if (name === 'PAGE') {
                        return;
                    }


            const variablePattern =
                    const field = fields[name];
                /%%([A-Za-z0-9_-]+)%%/g;


            let match;
                    if (!field) {
                        return;
                    }


            while (
                    if (this.state.config.fields[name]) {
                (
                         replaced.push(name);
                    match =
                    } else {
                         variablePattern.exec(
                        imported.push(name);
                            source
                    }
                        )
                ) !== null
            ) {
                const name =
                    match[1].toUpperCase();


                const known =
                    const clone = this.cloneConfigField(field);
                    knownVariables.has(name);


                const classes = [
                     if (!clone) {
                    'preload-editor-syntax-variable',
                         return;
                     known
                    }
                         ? 'preload-editor-syntax-variable-known'
                        : 'preload-editor-syntax-variable-unknown'
                ];


                decorations.push(
                     this.state.config.fields[name] = clone;
                    Decoration.mark({
                        class:
                            classes.join(' ')
                     }).range(
                        match.index,
                        match.index +
                            match[0].length
                    )
                );
            }


            const conditionPattern =
                    /*
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                    * If this variable was previously deleted,
                    * importing its definition explicitly should
                    * restore it.
                    */
                    if (Array.isArray(this.state.deletedVariables)) {
                        this.state.deletedVariables =
                            this.state.deletedVariables.filter(
                                deletedName =>
                                    String(deletedName).toUpperCase() !== name
                            );
                    }
                });


            while (
                 /*
                 (
                * Keep PAGE automatic regardless of what the
                    match =
                * imported config contained.
                        conditionPattern.exec(
                */
                            source
                 this.state.config.fields.PAGE =
                        )
                     this.getAutomaticPageField();
                 ) !== null
            ) {
                const fullStart =
                     match.index;


                 const fullEnd =
                 this.markConfigDirty();
                    match.index +
                    match[0].length;


                 decorations.push(
                 /*
                    Decoration.mark({
                * Keep the current selection when possible.
                        class:
                * Otherwise select the first imported definition.
                            'preload-editor-syntax-condition'
                */
                     }).range(
                const selectedStillExists = !!(
                         fullStart,
                     this.state.config.fields[
                        fullEnd
                         this.state.selectedVariable
                     )
                     ]
                 );
                 );


                 const directiveStart =
                 if (
                     match.index +
                    !selectedStillExists ||
                    match[0].indexOf('@');
                    this.state.selectedVariable === 'PAGE'
                ) {
                     this.state.selectedVariable =
                        selected[0] ||
                        'PAGE';
                }


                 const directiveEnd =
                 this.reanalyze();
                    directiveStart +
                    match[1].length +
                    1;


                 decorations.push(
                 this.renderVariablesBySelector();
                    Decoration.mark({
                this.renderVariableConfigurationBySelector();
                        class:
                this.renderAnalysisBySelector();
                            'preload-editor-syntax-directive'
                 this.updateEditorTools();
                    }).range(
                        directiveStart,
                        directiveEnd
                    )
                 );


                 if (
                 this.saveDraft();
                    match[1].toLowerCase() ===
                        'if' ||
                    match[1].toLowerCase() ===
                        'elseif'
                ) {
                    const expression =
                        match[2];


                     const expressionStart =
                let status =
                        match.index +
                     'Imported ' +
                        match[0].indexOf(
                    selected.length +
                            expression
                    ' variable ' +
                         );
                    (selected.length === 1
                        ? 'definition'
                         : 'definitions') +
                    '.';


                     const expressionPattern =
                if (replaced.length) {
                         /[A-Za-z0-9_-]+/g;
                     status +=
                         ' Replaced: ' +
                        replaced.join(', ') +
                        '.';
                }


                    let expressionMatch;
                this.setStatus(status, 'success');


                    while (
                mw.notify(status, {
                        (
                    type: 'success'
                            expressionMatch =
                });
                                expressionPattern
            } catch (error) {
                                    .exec(
                console.error(
                                        expression
                    'Preload Editor variable import:',
                                    )
                     error
                        ) !== null
                );
                     ) {
                        const name =
                            expressionMatch[0]
                                .toUpperCase();


                        if (
                this.setStatus(
                            !knownVariables.has(
                    error.message ||
                                name
                        'Could not import variable definitions.',
                            )
                    'error'
                        ) {
                );
                            continue;
                        }


                         const start =
                mw.notify(
                            expressionStart +
                    error.message ||
                            expressionMatch.index;
                         'Could not import variable definitions.',
                    {
                        type: 'error'
                    }
                );
            }
        },


                        const end =
        // =========================================================
                            start +
        // Preload parsing
                            expressionMatch[0].length;
        // =========================================================
 
        extractIncludeonly(source) {
            const match = String(source || '').match(
                /<includeonly>([\s\S]*?)<\/includeonly>/i
            );
 
            return match
                ? match[1].trim()
                : String(source || '').trim();
        },
 
        findVariables(content) {
            const variables = [];
            const pattern = /%%([A-Za-z0-9_-]+)%%/g;
 
            let match;
 
            while ((match = pattern.exec(content)) !== null) {
                const name = match[1].toUpperCase();


                        decorations.push(
                if (!variables.includes(name)) {
                            Decoration.mark({
                    variables.push(name);
                                class:
                                    'preload-editor-syntax-condition-variable'
                            }).range(
                                start,
                                end
                            )
                        );
                    }
                 }
                 }
             }
             }


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


         async installSyntaxHighlighting(
         findConditions(content) {
             view
             const conditions = [];
        ) {
             const pattern =
             await mw.loader.using(
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
                 'ext.CodeMirror.v6.lib'
            );


             const require =
             let match;
                mw.loader.require;


             const {
             while ((match = pattern.exec(content)) !== null) {
                 StateField,
                 conditions.push({
                StateEffect,
                    type: match[1].toLowerCase(),
                EditorView,
                    expression: match[2].trim(),
                 Decoration
                    index: match.index
             } = require(
                 });
                'ext.CodeMirror.v6.lib'
             }
            );


             const syntaxField =
             return conditions;
                StateField.define({
        },
                    create: state =>
                        this.createSyntaxDecorations(
                            state,
                            Decoration
                        ),


                    update: (
        findConditionVariables(content) {
                        decorations,
            const variables = [];
                        transaction
                    ) => {
                        if (
                            !transaction.docChanged
                        ) {
                            return decorations;
                        }


                        return this.createSyntaxDecorations(
            this.findConditions(content).forEach(condition => {
                            transaction.state,
                if (
                            Decoration
                    condition.type !== 'if' &&
                        );
                    condition.type !== 'elseif'
                    },
                ) {
                    return;
                }


                    provide: field =>
                const matches =
                        EditorView.decorations.from(
                    condition.expression.match(
                            field
                         /[A-Za-z0-9_-]+/g
                         )
                    ) || [];
                });


            view.dispatch({
                matches.forEach(name => {
                effects:
                    name = name.toUpperCase();
                     StateEffect.appendConfig.of(
 
                         syntaxField
                     if (!variables.includes(name)) {
                     )
                         variables.push(name);
                     }
                });
             });
             });


             this.editor.syntaxField =
             return variables;
                 syntaxField;
        },
 
        analyzePreload(content) {
            return {
                variables: this.findVariables(content),
                conditions: this.findConditions(content),
                conditionVariables:
                    this.findConditionVariables(content)
            };
        },
 
        // =========================================================
        // Config
        // =========================================================
 
        getAutomaticPageField() {
            return {
                label: 'Page Name',
                description: 'Name of the page.',
                 type: 'page',
                required: true,
                namespace: 'Main',
                'allow-new': true
            };
         },
         },


         // Editor tools
         emptyConfig() {
            return {
                settings: {
                    incomplete: 'add-stub'
                },


        getToolVariables() {
                fields: {
            const variables = new Set([
                    PAGE: this.getAutomaticPageField()
                 'PAGE'
                 }
             ]);
             };
        },


        normalizeConfig(config) {
             if (
             if (
                 this.analysis &&
                 !config ||
                 Array.isArray(
                typeof config !== 'object' ||
                    this.analysis.variables
                 Array.isArray(config)
                )
             ) {
             ) {
                 this.analysis.variables
                 config = this.emptyConfig();
                    .forEach(name => {
                        variables.add(
                            String(name)
                                .toUpperCase()
                        );
                    });
             }
             }


             if (
             if (
                 this.state.config &&
                 !config.settings ||
                 this.state.config.fields
                typeof config.settings !== 'object' ||
                 Array.isArray(config.settings)
             ) {
             ) {
                 Object.keys(
                 config.settings = {};
                    this.state.config.fields
                ).forEach(name => {
                    variables.add(
                        String(name)
                            .toUpperCase()
                    );
                });
             }
             }


             return Array.from(variables);
             if (
        },
                !config.fields ||
                typeof config.fields !== 'object' ||
                Array.isArray(config.fields)
            ) {
                config.fields = {};
            }


        createToolSelect(
             config.fields.PAGE =
             options,
                 this.getAutomaticPageField();
            className
        ) {
            const select =
                 $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                    );


             if (className) {
             return config;
                select.addClass(
        },
                    className
                );
            }


             options.forEach(option => {
        createDefaultField(name, analysis) {
                 $('<option>')
             return {
                    .attr(
                 label: this.humanizeName(name),
                        'value',
                description: '',
                        option.value
                type: analysis.conditionVariables.includes(name)
                     )
                     ? 'boolean'
                     .text(option.label)
                     : 'text'
                    .appendTo(select);
            };
            });
        },


             return select;
        humanizeName(name) {
             return String(name || '')
                .replace(/[_-]+/g, ' ')
                .replace(/([a-z])([A-Z])/g, '$1 $2')
                .replace(/\s+/g, ' ')
                .trim()
                .replace(/\b\w/g, char => char.toUpperCase());
         },
         },


         updateEditorToolVariables() {
         prepareConfigForVariables(config, analysis) {
             if (
             config = this.normalizeConfig(config);
                !this.editorTools
            ) {
                return;
            }


             const variables =
             const existing = config.fields || {};
                this.getToolVariables();


             const currentVariable =
             const deleted = new Set(
                 this.editorTools.variableSelect
                 Array.isArray(this.state.deletedVariables)
                     .val();
                     ? this.state.deletedVariables.map(
                        name => String(name).toUpperCase()
                    )
                    : []
            );


             const currentConditionVariable =
             const fields = {};
                this.editorTools.conditionVariableSelect
                    .val();


             const populate = (
             fields.PAGE =
                select,
                 this.getAutomaticPageField();
                current
            ) => {
                 select.empty();


                variables.forEach(
            analysis.variables.forEach(name => {
                    name => {
                if (
                        $('<option>')
                    name === 'PAGE' ||
                            .attr(
                    deleted.has(name)
                                'value',
                ) {
                                name
                    return;
                            )
                }
                            .text(name)
 
                            .appendTo(
                fields[name] =
                                select
                    existing[name] ||
                            );
                    this.createDefaultField(
                    }
                        name,
                 );
                        analysis
                    );
            });
 
            Object.keys(existing).forEach(name => {
                 const upper = name.toUpperCase();


                 if (
                 if (
                     current &&
                     upper !== 'PAGE' &&
                     variables.includes(
                     !fields[upper] &&
                        current
                    !deleted.has(upper)
                    )
                 ) {
                 ) {
                     select.val(
                     fields[upper] =
                        current
                         existing[name];
                    );
                } else if (
                    variables.length
                ) {
                    select.val(
                         variables[0]
                    );
                 }
                 }
             };
             });


             populate(
             config.fields = fields;
                this.editorTools
                    .variableSelect,
                currentVariable
            );


             populate(
             return config;
                this.editorTools
                    .conditionVariableSelect,
                currentConditionVariable
            );
         },
         },


         renderEditorTools() {
         validateConfig(config, analysis) {
             const tools =
             const errors = [];
                $('<div>')
            const warnings = [];
                    .addClass(
                        'preload-editor-tools'
                    );


             const variableGroup =
             if (
                 $('<div>')
                !config ||
                     .addClass(
                typeof config !== 'object' ||
                         'preload-editor-tool-group'
                 Array.isArray(config)
                     );
            ) {
                return {
                    valid: false,
                     errors: [
                         'Config must be an object.'
                     ],
                    warnings: []
                };
            }


             $('<span>')
             if (
                 .addClass(
                 !config.fields ||
                    'preload-editor-tool-label'
                typeof config.fields !== 'object' ||
                 )
                 Array.isArray(config.fields)
                 .text('Variable')
            ) {
                .appendTo(
                 return {
                     variableGroup
                    valid: false,
                 );
                    errors: [
                        '"fields" must be an object.'
                    ],
                     warnings
                 };
            }


             const variableSelect =
             const configured =
                 this.createToolSelect(
                 Object.keys(
                     [],
                     config.fields
                     'preload-editor-variable-select'
                ).map(
                     name =>
                        name.toUpperCase()
                 );
                 );


             const insertVariable =
             analysis.variables.forEach(name => {
                 this.createButton(
                 if (!configured.includes(name)) {
                     'Insert',
                     errors.push(
                    'preload-editor-tool-button',
                        'Variable "' +
                     () => {
                        name +
                        this.insertVariable(
                        '" is not configured.'
                            variableSelect.val()
                     );
                         );
                }
                    }
            });
                );
 
            configured.forEach(name => {
                if (!analysis.variables.includes(name)) {
                    warnings.push(
                         'Field "' +
                        name +
                        '" is not used by the preload.'
                    );
                }
            });


             const wrapVariable =
             if (!configured.includes('PAGE')) {
                this.createButton(
                errors.push(
                    'Wrap selection',
                    'PAGE must always be configured.'
                    'preload-editor-tool-button',
                    () => {
                        this.wrapSelectionWithVariable(
                            variableSelect.val()
                        );
                    }
                 );
                 );
            }


             variableGroup.append(
             Object.entries(config.fields).forEach(([name, field]) => {
                 variableSelect,
                 if (
                insertVariable,
                    !field ||
                wrapVariable
                    typeof field !== 'object' ||
            );
                    Array.isArray(field)
 
                 ) {
            const conditionGroup =
                     errors.push(
                 $('<div>')
                         'Field "' +
                     .addClass(
                        name +
                         'preload-editor-tool-group'
                        '" must be an object.'
                     );
                     );


            $('<span>')
                     return;
                .addClass(
                 }
                     'preload-editor-tool-label'
                 )
                .text('Condition')
                .appendTo(
                    conditionGroup
                );


            const conditionVariableSelect =
                if (!field.type) {
                 this.createToolSelect(
                    warnings.push(
                     [],
                        'Field "' +
                    'preload-editor-condition-variable-select'
                        name +
                );
                        '" has no type.'
                    );
                }
               
                if (
                    field.type === 'file'
                 ) {
                    if (
                        field.accept !== undefined &&
                        !Array.isArray(field.accept)
                     ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "accept" as an array.'
                        );
                    }


            const conditionTypeSelect =
                    if (
                this.createToolSelect(
                        Array.isArray(field.accept) &&
                    [
                         field.accept.some(
                         {
                             value =>
                             value: 'if',
                                !String(
                            label: '@if'
                                    value || ''
                        },
                                ).trim()
                        {
                         )
                            value: 'elseif',
                    ) {
                            label: '@elseif'
                        errors.push(
                         },
                             'File field "' +
                        {
                             name +
                            value: 'else',
                             '" contains an empty accepted MIME type.'
                             label: '@else'
                         );
                        },
                     }
                        {
                             value: 'endif',
                             label: '@endif'
                         }
                     ],
                    'preload-editor-condition-type-select'
                );


            const insertCondition =
                    if (
                this.createButton(
                        field.filename !== undefined &&
                    'Insert',
                        typeof field.filename !== 'string'
                     'preload-editor-tool-button',
                     ) {
                    () => {
                         errors.push(
                         this.insertCondition(
                             'File field "' +
                             conditionTypeSelect.val(),
                            name +
                             conditionVariableSelect.val()
                             '" must have "filename" as a string.'
                         );
                         );
                     }
                     }
                );


            conditionGroup.append(
                    if (
                conditionVariableSelect,
                        field.filetext !== undefined &&
                conditionTypeSelect,
                        typeof field.filetext !== 'string'
                insertCondition
                    ) {
            );
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "filetext" as a string.'
                        );
                    }


            tools.append(
                    if (
                variableGroup,
                        !field.accept ||
                conditionGroup
                        !field.accept.length
            );
                    ) {
                        warnings.push(
                            'File field "' +
                            name +
                            '" has no accepted MIME types.'
                        );
                    }


            this.editorTools = {
                    if (
                root: tools,
                        !field.filename
                variableSelect,
                    ) {
                wrapVariable,
                        warnings.push(
                conditionVariableSelect,
                            'File field "' +
                conditionTypeSelect,
                            name +
                 insertCondition
                            '" has no filename template.'
            };
                        );
                    }
                 }


            this.updateEditorToolVariables();
                if (
                    (
                        field.type === 'select' ||
                        field.type === 'multiselect'
                    ) &&
                    !field.source &&
                    !Array.isArray(field.options)
                ) {
                    warnings.push(
                        'Field "' +
                        name +
                        '" is a ' +
                        field.type +
                        ' without a source or manual options.'
                    );
                }


            return tools;
                if (field.type === 'file') {
        },
                    if (
                        field.filename !== undefined &&
                        typeof field.filename !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" has an invalid "filename" value.'
                        );
                    }


        updateEditorTools() {
                    if (
            this.updateEditorToolVariables();
                        field.filetext !== undefined &&
        },
                        typeof field.filetext !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" has an invalid "filetext" value.'
                        );
                    }


        // CodeMirror mounting
                    if (
                        field.accept !== undefined &&
                        (
                            !Array.isArray(field.accept) ||
                            field.accept.some(
                                value =>
                                    typeof value !== 'string' ||
                                    !value.trim()
                            )
                        )
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "accept" as an array of MIME types.'
                        );
                    }
                }
            });


        async mountCodeMirror(
             return {
             textarea
                valid: !errors.length,
        ) {
                 errors,
            return new Promise(
                warnings
                 (resolve, reject) => {
            };
                    let settled = false;
        },


                    const cleanup = () => {
        // =========================================================
                        mw.hook(
        // Transformation helpers
                            'dev.CCM.ready'
        // =========================================================
                        ).remove(
                            onReady
                        );


                        mw.hook(
        transformationObject(
                            'dev.CCM.load'
            transform,
                        ).remove(
            prefix,
                            onLoad
            suffix,
                        );
            from,
                    };
            to
        ) {
            if (!transform) {
                return null;
            }


                     const onReady =
            switch (transform) {
                         async cm => {
                case 'remove-prefix':
                             if (
                     return prefix
                                settled ||
                         ? {
                                !cm ||
                             transform,
                                cm.textarea !==
                            prefix
                                    textarea ||
                        }
                                !cm.view
                        : null;
                            ) {
                                return;
                            }


                             settled = true;
                case 'remove-suffix':
                             cleanup();
                    return suffix
                        ? {
                             transform,
                             suffix
                        }
                        : null;


                             const view =
                case 'replace':
                                cm.view;
                    return from === ''
                        ? null
                        : {
                            transform,
                            from,
                             to: to || ''
                        };


                            const inputHandler =
                default:
                                () => {
                    return null;
                                    this.state.preloadContent =
            }
                                        view.state.doc
        },
                                            .toString();


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


                                    this.reanalyze();
        createSection(title, className) {
            const section = $('<section>')
                .addClass('preload-editor-section');


                                    this.renderVariablesBySelector();
            if (className) {
                                    this.renderAnalysisBySelector();
                section.addClass(className);
                                    this.renderConfigPreviewBySelector();
            }
                                    this.updateEditorTools();
                                };


                            view.dom.addEventListener(
            $('<h2>')
                                'input',
                .addClass('preload-editor-section-title')
                                inputHandler
                .text(title)
                            );
                .appendTo(section);


                            view.dom.classList.add(
            return section;
                                'preload-editor-codemirror'
        },
                            );


                            const wrapper =
        createButton(label, className, callback) {
                                view.dom.closest(
            const button = $('<button>')
                                    '.ext-codemirror-wrapper'
                .attr('type', 'button')
                                );
                .addClass('preload-editor-button')
                .text(label);


                            if (wrapper) {
            if (className) {
                                wrapper.classList.add(
                button.addClass(className);
                                    'preload-editor-codemirror-wrapper'
            }
                                );
 
                            }
            if (callback) {
                button.on(
                    'click.preloadEditor',
                    callback
                );
            }


                            this.editor = {
            return button;
                                cm,
        },
                                view,
                                textarea,
                                inputHandler
                            };


                            this.editorView =
        fieldRow(label, input, description) {
                                view;
            const row = $('<div>')
                .addClass('preload-editor-field');


                            this.state.preloadContent =
            $('<label>')
                                view.state.doc
                .addClass('preload-editor-label')
                                    .toString();
                .text(label)
                .appendTo(row);


                            try {
            if (description) {
                                await this.installSyntaxHighlighting(
                $('<div>')
                                    view
                    .addClass('preload-editor-help')
                                );
                    .text(description)
                    .appendTo(row);
            }


                                resolve(view);
            row.append(input);
                            } catch (
                                error
                            ) {
                                reject(error);
                            }
                        };


                    const onLoad =
            return row;
                        loader => {
        },
                            if (
                                settled ||
                                typeof loader !==
                                    'function'
                            ) {
                                return;
                            }


                            try {
        textInput(value, placeholder) {
                                loader(textarea);
            return $('<input>')
                            } catch (
                .attr({
                                error
                    type: 'text',
                            ) {
                    value: value == null ? '' : value,
                                settled = true;
                    placeholder: placeholder || ''
                                cleanup();
                })
                                reject(
                .addClass('preload-editor-input');
                                    error
        },
                                );
                            }
                        };


                    mw.hook(
        textarea(value) {
                        'dev.CCM.ready'
            return $('<textarea>')
                    ).add(onReady);
                .val(value == null ? '' : value)
                .addClass('preload-editor-textarea');
        },


                    mw.hook(
        checkbox(checked) {
                        'dev.CCM.load'
            return $('<input>')
                    ).add(onLoad);
                .attr('type', 'checkbox')
                 }
                .prop('checked', !!checked)
            );
                 .addClass('preload-editor-checkbox');
         },
         },


         // Main UI
         select(options, value) {
            const select = $('<select>')
                .addClass('preload-editor-select');


        init() {
            options.forEach(option => {
            const root =
                 $('<option>')
                 $('#mw-content-text');
                    .attr('value', option.value)
                    .text(option.label)
                    .prop(
                        'selected',
                        String(option.value) ===
                        String(value === undefined ? '' : value)
                    )
                    .appendTo(select);
            });


             if (!root.length) {
             return select;
                return;
        },
            }


            this.root =
        // =========================================================
                $('<div>')
        // In-wiki dialogs
                    .attr(
        // =========================================================
                        'id',
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                    );


             root.append(this.root);
        createConfirmDialog(
            this.renderShell();
            title,
        },
            message,
            confirmLabel,
            cancelLabel
        ) {
             return new Promise(resolve => {
                const overlay = $('<div>')
                    .addClass('preload-editor-prompt-overlay');


        renderShell() {
                const box = $('<div>')
            this.destroyEditor();
                    .addClass('preload-editor-prompt');
            this.root.empty();


            const loader =
                $('<h2>')
                this.createSection(
                    .text(title)
                     'Preload'
                     .appendTo(box);
                );


            const titleInput =
                const messageLines =
                this.textInput(
                    String(message || '').split('\n');
                    this.state.preloadTitle,
                    'Template:Example/Preload'
                ).attr(
                    'id',
                    'preload-editor-title-input'
                );


            const loadButton =
                 messageLines.forEach(line => {
                 this.createButton(
                     if (line === '') {
                     'Load',
                        $('<div>')
                    'preload-editor-button-primary',
                            .addClass(
                    () =>
                                'preload-editor-prompt-spacer'
                        this.loadExisting(
                            )
                             titleInput.val()
                             .appendTo(box);
                        )
                );


            const newButton =
                        return;
                this.createButton(
                     }
                    'New',
                    '',
                     () => this.startNew()
                );


            const sampleButton =
                    $('<p>')
                this.createButton(
                        .text(line)
                    'Load Sample Page',
                         .appendTo(box);
                    '',
                 });
                    () =>
                         this.loadSamplePage()
                 );


            const loadRow =
                const buttons = $('<div>')
                $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-toolbar'
                         'preload-editor-prompt-buttons'
                    )
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                     );
                     );


            loader.append(loadRow);
                const cancel = this.createButton(
            this.root.append(loader);
                    cancelLabel || 'Cancel',
                    '',
                    () => {
                        overlay.remove();
                        resolve(false);
                    }
                );


            this.status =
                const confirm = this.createButton(
                 $('<div>')
                    confirmLabel || 'Confirm',
                    'preload-editor-button-primary',
                    () => {
                        overlay.remove();
                        resolve(true);
                    }
                );
 
                buttons.append(cancel, confirm);
                box.append(buttons);
                overlay.append(box);
 
                $(document.body).append(overlay);
 
                const close = value => {
                    if (value === 'Escape') {
                        cancel.trigger('click');
                    }
                };
 
                overlay.on(
                    'keydown.preloadEditor',
                    event => {
                        close(event.key);
                    }
                );
 
                 overlay.attr('tabindex', '-1');
                overlay.trigger('focus');
 
                overlay.on(
                    'click.preloadEditor',
                    event => {
                        if (event.target === overlay.get(0)) {
                            cancel.trigger('click');
                        }
                    }
                );
            });
        },
 
        createPromptDialog(title, message) {
            return new Promise(resolve => {
                const overlay = $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-status'
                         'preload-editor-prompt-overlay'
                     );
                     );


            this.root.append(
                 const box = $('<div>')
                 this.status
            );
 
            this.workspace =
                $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-workspace'
                         'preload-editor-prompt'
                     );
                     );


            this.root.append(
                $('<h2>')
                this.workspace
                    .text(title)
            );
                    .appendTo(box);


            this.renderWelcome();
                $('<p>')
        },
                    .text(message)
                    .appendTo(box);


        renderWelcome() {
                const input = this.textInput();
            this.workspace.empty();


            const section =
                const buttons = $('<div>')
                 this.createSection(
                    .addClass(
                     'Get started'
                        'preload-editor-prompt-buttons'
                    );
 
                 const cancel = this.createButton(
                     'Cancel',
                    '',
                    () => {
                        overlay.remove();
                        resolve(null);
                    }
                 );
                 );


            $('<p>')
                 const confirm = this.createButton(
                 .text(
                     'Load',
                     'Load an existing preload, create a new one, or start from a sample wiki page.'
                    'preload-editor-button-primary',
                )
                    () => {
                .appendTo(section);
                        const value =
                            String(
                                input.val() || ''
                            ).trim();


            const tips =
                        if (!value) {
                $('<ul>')
                            return;
                    .addClass(
                         }
                         'preload-editor-tips'
                    );


            [
                        overlay.remove();
                'Existing preload: loads the preload and its config.json if available.',
                        resolve(value);
                '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);
            });
 
            section.append(tips);
            this.workspace.append(
                 section
            );
        },


        setStatus(
                buttons.append(cancel, confirm);
            message,
            type
        ) {
            if (!this.status) {
                return;
            }


            this.status
                 box.append(
                 .removeClass(
                     input,
                     'is-error',
                     buttons
                    'is-success',
                    'is-warning',
                     'is-loading'
                 );
                 );


            if (type) {
                overlay.append(box);
                 this.status.addClass(
 
                    'is-' + type
                 $(document.body).append(overlay);
                );
            }


            this.status.text(
                input.trigger('focus');
                message || ''
            );
        },


        // Load / create
                input.on(
                    'keydown.preloadEditor',
                    event => {
                        if (event.key === 'Enter') {
                            confirm.trigger('click');
                        }


        async loadExisting(title) {
                        if (event.key === 'Escape') {
            title =
                            cancel.trigger('click');
                String(title || '')
                        }
                    .trim();
                     }
 
            if (!title) {
                mw.notify(
                    'Enter a preload page title first.',
                     { type: 'warn' }
                 );
                 );
                return;
            });
            }
        },


             this.setStatus(
        createConfigReviewDialog() {
                 'Loading preload...',
             return new Promise(resolve => {
                'loading'
                 const overlay = $('<div>')
            );
                    .addClass(
                        'preload-editor-prompt-overlay preload-editor-code-review-overlay'
                    );


            try {
                 const box = $('<div>')
                 const page =
                     .addClass(
                     await this.getPage(
                         'preload-editor-prompt preload-editor-code-review'
                         title
                     );
                     );


                 if (!page) {
                 $('<h2>')
                     const suggestions =
                     .text('Review config.json')
                        await this.findSimilarPreloads(
                    .appendTo(box);
                            title
                        );


                     this.renderMissingPage(
                $('<p>')
                         title,
                    .text(
                         suggestions
                        'This is the config.json that will be saved for the current preload.'
                     )
                    .appendTo(box);
 
                const code = $('<pre>')
                    .addClass(
                        'preload-editor-config-review'
                    )
                    .text(this.getConfigJSON());
 
                const codeWrap = $('<div>')
                    .addClass(
                         'preload-editor-config-review-wrap'
                    )
                    .append(code);
 
                box.append(codeWrap);
 
                const buttons = $('<div>')
                    .addClass(
                         'preload-editor-prompt-buttons'
                     );
                     );


                    this.setStatus(
                const close = this.createButton(
                        'Preload not found.',
                    'Close',
                        'warning'
                    'preload-editor-button-primary',
                     );
                    () => {
                        overlay.remove();
                        resolve();
                     }
                );
 
                buttons.append(close);
                box.append(buttons);
                overlay.append(box);
 
                $(document.body).append(overlay);


                    return;
                overlay.attr('tabindex', '-1');
                 }
                 overlay.trigger('focus');


                 await this.loadDefinition(
                 overlay.on(
                     page.title,
                     'keydown.preloadEditor',
                     page.content
                     event => {
                        if (event.key === 'Escape') {
                            close.trigger('click');
                        }
                    }
                 );
                 );


                 this.setStatus(
                 overlay.on(
                     'Preload loaded.',
                     'click.preloadEditor',
                     'success'
                     event => {
                );
                        if (event.target === overlay.get(0)) {
            } catch (
                            close.trigger('click');
                error
                        }
            ) {
                     }
                console.error(
                    'Preload Editor:',
                     error
                 );
                 );
            });
        },


                this.setStatus(
        async confirmSave(mode, title) {
                    error.message ||
            const configTitle =
                        'Could not load preload.',
                title.replace(/\/+$/, '') +
                    'error'
                 '/config.json';
                 );
            }
        },


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


             if (!query) {
             if (mode === 'preload') {
                 query = title;
                saveDescription =
                    'Save the preload page only?';
            } else if (mode === 'config') {
                 saveDescription =
                    'Save the config.json page only?';
            } else {
                saveDescription =
                    'Save both the preload and config.json?';
             }
             }


             try {
             const pages = [];
                const results =
                    await this.searchPages(
                        query
                    );


                 return results.filter(
            if (mode === 'preload' || mode === 'both') {
                     result =>
                 pages.push(
                        /\/Preload$/i.test(
                     'Preload: ' +
                            result.title
                    title
                        )
                 );
                 );
             } catch (
             }
                error
 
             ) {
             if (mode === 'config' || mode === 'both') {
                 console.error(
                 pages.push(
                     'Preload Editor search:',
                     'Config: ' +
                     error
                     configTitle
                 );
                 );


                 return [];
                 pages.push(
                    'Config content model: JSON'
                );
             }
             }
        },


        renderMissingPage(
            const message =
             title,
                saveDescription +
             suggestions
                '\n\n' +
         ) {
                pages.join('\n') +
             this.workspace.empty();
                '\n\nThis will write the changes to the wiki.';
 
             return this.createConfirmDialog(
                'Confirm save',
                message,
                'Save',
                'Cancel'
             );
        },
 
        // =========================================================
        // CodeMirror
         // =========================================================
                destroyEditor() {
             if (!this.editor) {
                this.editorView = null;
                this.editorTools = null;
                return;
            }
 
            const {
                view,
                inputHandler
            } = this.editor;


             const section =
             if (view && inputHandler) {
                 this.createSection(
                 view.dom.removeEventListener(
                     'Preload not found'
                     'input',
                    inputHandler
                 );
                 );
            }


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


             if (suggestions.length) {
             this.editor = null;
                $('<h3>')
            this.editorView = null;
                    .text(
            this.editorTools = null;
                        'Similar preloads'
        },
                    )
 
                    .appendTo(section);
        getEditorView() {
            return this.editorView;
        },
 
        getEditorContent() {
            if (
                this.editorView &&
                this.editorView.state
            ) {
                return this.editorView.state.doc.toString();
            }


                const list =
            return this.state.preloadContent || '';
                    $('<div>')
        },
                        .addClass(
                            'preload-editor-suggestions'
                        );


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


                 section.append(list);
            if (!this.editorView) {
                 this.state.preloadContent = content;
                return;
             }
             }


             section.append(
             const current =
                 this.createButton(
                 this.editorView.state.doc.toString();
                    'Create this preload',
 
                    'preload-editor-button-primary',
            if (current === content) {
                    () =>
                 return;
                        this.startNew(
             }
                            title
                        )
                 )
             );


             this.workspace.append(
             this.editorView.dispatch({
                 section
                 changes: {
             );
                    from: 0,
                    to: this.editorView.state.doc.length,
                    insert: content
                }
             });
         },
         },


         async startNew(title) {
         getEditorSelection() {
             title =
             if (
                 String(title || '')
                 !this.editorView ||
                    .trim();
                !this.editorView.state
            ) {
                return null;
            }
 
            const selection =
                this.editorView.state.selection.main;


             this.state = {
             return {
                 preloadTitle: title,
                 from: selection.from,
                 preloadSource: '',
                 to: selection.to,
                 preloadContent:
                 text: this.editorView.state.doc.sliceString(
                    this.defaults.preload,
                     selection.from,
                config:
                    selection.to
                     this.emptyConfig(),
                 )
                variables: ['PAGE'],
                 conditions: [],
                mode: 'new',
                dirty: true
             };
             };
        },


            this.renderEditor();
        focusEditor() {
 
             if (this.editorView) {
             this.setStatus(
                 this.editorView.focus();
                 'Creating a new preload.',
             }
                'success'
             );
         },
         },


         async loadSamplePage() {
         insertEditorText(
             const result =
             text,
                await this.createPromptDialog(
            selectionFrom,
                    'Load Sample Page',
            selectionTo,
                    'Enter the title of a normal wiki page to use as the starting point.'
            cursorPosition
                );
        ) {
 
             if (!this.editorView) {
             if (!result) {
                 return;
                 return;
             }
             }


             this.setStatus(
             const view = this.editorView;
                'Loading sample page...',
            const selection = view.state.selection.main;
                'loading'
            );


             try {
             const from =
                 const page =
                 selectionFrom === undefined
                     await this.getPage(
                    ? selection.from
                        result
                    : selectionFrom;
                     );
 
            const to =
                selectionTo === undefined
                     ? selection.to
                    : selectionTo;
 
            const cursor =
                cursorPosition === undefined
                    ? from + text.length
                     : cursorPosition;


                 if (!page) {
            view.dispatch({
                     throw new Error(
                 changes: {
                        'Sample page not found: ' +
                     from,
                        result
                    to,
                     );
                    insert: text
                },
                selection: {
                     anchor: cursor
                 }
                 }
            });
            view.focus();
        },


                const sample =
        // =========================================================
                    this.convertSampleToPreload(
        // Editor tools
                        page.content
        // =========================================================
                    );


                this.state = {
        wrapSelectionWithVariable() {
                    preloadTitle: '',
            const selected =
                    preloadSource: '',
                this.getEditorSelection();
                    preloadContent:
                        sample,
                    config:
                        this.emptyConfig(),
                    variables: ['PAGE'],
                    conditions: [],
                    mode: 'new',
                    dirty: true
                };


                this.reanalyze();
            if (
                 this.renderEditor();
                 !selected ||
 
                 selected.from === selected.to
                 this.setStatus(
                    'Sample page loaded.',
                    'success'
                );
            } catch (
                error
             ) {
             ) {
                 console.error(
                 this.focusEditor();
                    'Preload Editor sample:',
                 return;
                    error
                );
 
                 this.setStatus(
                    error.message ||
                        'Could not load sample page.',
                    'error'
                );
             }
             }
        },


        convertSampleToPreload(
             const replacement =
             source
                 '%%' +
        ) {
                selected.text +
            let content =
                '%%';
                 String(source || '')
                    .trim();


             const include =
             this.insertEditorText(
                 content.match(
                replacement,
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                selected.from,
                 );
                selected.to,
                 selected.from + replacement.length
            );
        },
 
        insertVariable(name) {
            name = String(name || '')
                 .trim()
                .toUpperCase();


             if (include) {
             if (!name) {
                 content =
                 return;
                    include[1].trim();
             }
             }


             content =
             const selected =
                 content
                 this.getEditorSelection();
                    .replace(
 
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
            if (!selected) {
                        ''
                return;
                    )
            }
                    .trim();


             return (
             this.insertEditorText(
                 '<includeonly>\n' +
                 '%%' + name + '%%',
                content +
                 selected.from,
                 '\n</includeonly>\n' +
                 selected.to
                 '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
             );
             );
         },
         },


         createPromptDialog(
         insertCondition(
             title,
             type,
             message
             variable
         ) {
         ) {
             return new Promise(
             type = String(type || '').toLowerCase();
                resolve => {
            variable = String(variable || '')
                    const overlay =
                .trim()
                        $('<div>')
                .toUpperCase();
                            .addClass(
                                'preload-editor-prompt-overlay'
                            );


                    const box =
            const selected =
                        $('<div>')
                this.getEditorSelection();
                            .addClass(
 
                                'preload-editor-prompt'
            if (!selected) {
                            );
                return;
            }


                    $('<h2>')
            let text;
                        .text(title)
            let cursorOffset;
                        .appendTo(box);


                    $('<p>')
            switch (type) {
                        .text(message)
                case 'if': {
                         .appendTo(box);
                    if (!variable) {
                         return;
                    }


                     const input =
                     const opening =
                         this.textInput();
                         '<!-- @if ' +
                        variable +
                        ' -->';


                     const buttons =
                     const closing =
                         $('<div>')
                         '<!-- @endif -->';
                            .addClass(
                                'preload-editor-prompt-buttons'
                            );


                     const cancel =
                     if (
                         this.createButton(
                        selected.from !==
                             'Cancel',
                         selected.to
                             '',
                    ) {
                             () => {
                        text =
                                overlay.remove();
                             opening +
                                resolve(
                             '\n' +
                                    null
                             selected.text +
                                );
                            '\n' +
                             }
                             closing;
                        );


                     const confirm =
                        cursorOffset = text.length;
                         this.createButton(
                     } else {
                             'Load',
                         text =
                             'preload-editor-button-primary',
                             opening +
                             () => {
                             '\n\n' +
                                const value =
                             closing;
                                    String(
                                        input.val() ||
                                        ''
                                    ).trim();


                                if (!value) {
                        cursorOffset =
                                    return;
                            opening.length + 1;
                                }
                    }


                                overlay.remove();
                    break;
                                resolve(
                }
                                    value
                                );
                            }
                        );


                     buttons.append(
                case 'elseif':
                         cancel,
                     text =
                        confirm
                         '<!-- @elseif -->';
                    );


                     box.append(
                     cursorOffset =
                         input,
                         text.length;
                        buttons
                    );


                     overlay.append(box);
                     break;
                    $(document.body).append(
                        overlay
                    );


                     input.trigger(
                case 'else':
                         'focus'
                     text =
                    );
                         '<!-- @else -->';


                     input.on(
                     cursorOffset =
                         'keydown.preloadEditor',
                         text.length;
                        event => {
                            if (
                                event.key ===
                                'Enter'
                            ) {
                                confirm.trigger(
                                    'click'
                                );
                            }


                            if (
                     break;
                                event.key ===
                                'Escape'
                            ) {
                                cancel.trigger(
                                    'click'
                                );
                            }
                        }
                     );
                }
            );
        },


        async loadDefinition(
                 case 'endif':
            title,
                     text =
            source
                        '<!-- @endif -->';
        ) {
            const content =
                 this.extractIncludeonly(
                     source
                );


            let config =
                    cursorOffset =
                this.emptyConfig();
                        text.length;


            const configTitle =
                     break;
                title.replace(
                     /\/+$/,
                    ''
                ) +
                '/config.json';


            const configPage =
                 default:
                 await this.getPage(
                     return;
                     configTitle
            }
                );


             if (configPage) {
            this.insertEditorText(
                 try {
                text,
                     config =
                selected.from,
                         JSON.parse(
                selected.to,
                             configPage.content
                selected.from + cursorOffset
            );
        },
 
        getToolVariables() {
            const variables =
                new Set(['PAGE']);
 
             if (
                this.analysis &&
                this.analysis.variables
            ) {
                 this.analysis.variables.forEach(
                     name => {
                         variables.add(
                             String(name).toUpperCase()
                         );
                         );
                } catch (
                    }
                    error
                 );
                 ) {
                    throw new Error(
                        'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
             }
             }


             const analysis =
             return Array.from(variables);
                this.analyzePreload(
        },
                    content
                );


             config =
        createToolSelect(
                 this.prepareConfigForVariables(
            options,
                    config,
            className
                     analysis
        ) {
                );
             const select =
                 $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                     );


             this.state = {
             if (className) {
                 preloadTitle: title,
                 select.addClass(className);
                preloadSource: source,
            }
                preloadContent: source,
 
                config,
            options.forEach(
                 variables:
                 option => {
                     analysis.variables,
                     $('<option>')
                conditions:
                        .attr(
                    analysis.conditions,
                            'value',
                mode: 'edit',
                            option.value
                 dirty: false
                        )
             };
                        .text(option.label)
                        .appendTo(select);
                 }
             );


             this.renderEditor();
             return select;
         },
         },


         // Editor
         updateEditorToolVariables() {
            if (!this.editorTools) {
                return;
            }


        renderEditor() {
             const variables =
             this.destroyEditor();
                this.getToolVariables();
            this.workspace.empty();


             const layout =
             const current =
                 $('<div>')
                 this.editorTools
                     .addClass(
                     .variableSelect
                        'preload-editor-layout'
                     .val();
                     );


             const left =
             const currentCondition =
                 $('<div>')
                 this.editorTools
                     .addClass(
                     .conditionVariableSelect
                        'preload-editor-left'
                     .val();
                     );


             const right =
             const populate = (
                 $('<div>')
                select,
                    .addClass(
                 value
                        'preload-editor-right'
            ) => {
                    );
                select.empty();


            const editorSection =
                variables.forEach(
                this.createSection(
                    name => {
                    'Preload',
                        $('<option>')
                     'preload-editor-main-section'
                            .attr(
                                'value',
                                name
                            )
                            .text(name)
                            .appendTo(select);
                     }
                 );
                 );


            const meta =
                if (
                 $('<div>')
                    value &&
                     .addClass(
                    variables.includes(value)
                         'preload-editor-meta'
                ) {
                    select.val(value);
                 } else if (
                    variables.length
                ) {
                     select.val(
                         variables[0]
                     );
                     );
                }
            };


             $('<strong>')
             populate(
                 .text(
                 this.editorTools.variableSelect,
                    this.state.preloadTitle ||
                current
                    'New preload'
            );
                 )
 
                .appendTo(meta);
            populate(
                this.editorTools.conditionVariableSelect,
                 currentCondition
            );
        },


             if (
        updateConditionVariableVisibility() {
                this.state.mode ===
             if (!this.editorTools) {
                'new'
                 return;
            ) {
                 $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                    .appendTo(meta);
             }
             }


             editorSection.append(
             const type =
                meta
                this.editorTools
            );
                    .conditionTypeSelect
                    .val();


             const editorTools =
             this.editorTools
                 this.renderEditorTools();
                 .conditionVariableWrap
                .toggle(
                    type === 'if'
                );
        },


             editorSection.append(
        renderEditorTools() {
                editorTools
             const tools =
            );
                $('<div>')
                    .addClass(
                        'preload-editor-tools'
                    );


             const textarea =
             const wrapGroup =
                 this.textarea(
                 $('<div>')
                    this.state.preloadContent
                )
                    .attr(
                        'id',
                        'preload-editor-content'
                    )
                     .addClass(
                     .addClass(
                         'preload-editor-content'
                         'preload-editor-tool-group preload-editor-tool-group-wrap'
                     );
                     );


             const editorMount =
             wrapGroup.append(
                 $('<div>')
                 this.createButton(
                     .addClass(
                    'Wrap selection',
                        'preload-editor-codemirror-mount'
                     'preload-editor-tool-button',
                     )
                     () =>
                    .append(textarea);
                        this.wrapSelectionWithVariable()
 
                 )
            editorSection.append(
                 editorMount
             );
             );


            left.append(
             const variableGroup =
                editorSection
            );
 
             const analysisSection =
                this.createSection(
                    'Analysis'
                );
 
            const analysisContainer =
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-analysis'
                         'preload-editor-tool-group preload-editor-tool-group-variable'
                     );
                     );


             analysisSection.append(
             $('<span>')
                 analysisContainer
                .addClass(
            );
                    'preload-editor-tool-label'
                )
                .text(
                    'Insert existing variable'
                 )
                .appendTo(variableGroup);


             const variablesSection =
             const variableSelect =
                 this.createSection(
                 this.createToolSelect(
                     'Variables'
                    [],
                     'preload-editor-variable-select'
                 );
                 );


             const variableContainer =
             variableGroup.append(
                $('<div>')
                variableSelect,
                    .addClass(
                        'preload-editor-variables'
                    );


            variablesSection.append(
                this.createButton(
                 variableContainer
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertVariable(
                            variableSelect.val()
                        )
                 )
             );
             );


             const configSection =
             const conditionGroup =
                this.createSection(
                    'Generated configuration'
                );
 
            const configPreview =
                $('<pre>')
                    .addClass(
                        'preload-editor-config-preview'
                    );
 
            configSection.append(
                configPreview
            );
 
            const actions =
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass(
                         'preload-editor-actions'
                         'preload-editor-tool-group preload-editor-tool-group-condition'
                     );
                     );


             const save =
             $('<span>')
                 this.createButton(
                 .addClass(
                    'Save Preload + Config',
                     'preload-editor-tool-label'
                     'preload-editor-button-primary',
                )
                    () => this.saveAll()
                .text('Insert condition')
                 );
                 .appendTo(conditionGroup);


             const savePreload =
             const conditionTypeSelect =
                 this.createButton(
                 this.createToolSelect(
                     'Save Preload Only',
                     [
                    '',
                        {
                     () =>
                            value: 'if',
                        this.savePreloadOnly()
                            label: '@if'
                        },
                        {
                            value: 'elseif',
                            label: '@elseif'
                        },
                        {
                            value: 'else',
                            label: '@else'
                        },
                        {
                            value: 'endif',
                            label: '@endif'
                        }
                     ],
                    'preload-editor-condition-type-select'
                 );
                 );


             actions.append(
             const conditionVariableSelect =
                save,
                this.createToolSelect(
                 savePreload
                    [],
            );
                    'preload-editor-condition-variable-select'
                 );


             right.append(
             const conditionVariableWrap =
                analysisSection,
                $('<span>')
                variablesSection,
                    .addClass(
                configSection,
                        'preload-editor-condition-variable'
                actions
                    )
            );
                    .append(
                        conditionVariableSelect
                    );


             layout.append(
             const insertCondition =
                 left,
                this.createButton(
                 right
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        )
                 );
 
            conditionTypeSelect.on(
                'change.preloadEditor',
                 () =>
                    this.updateConditionVariableVisibility()
             );
             );


             this.workspace.append(
             conditionGroup.append(
                 layout
                 conditionTypeSelect,
                conditionVariableWrap,
                insertCondition
             );
             );


             this.reanalyze();
             tools.append(
            this.renderVariables(
                wrapGroup,
                 variableContainer
                 variableGroup,
            );
                 conditionGroup
            this.renderAnalysis(
                 analysisContainer
             );
             );
            this.renderConfigPreview(
                configPreview
            );
            this.updateEditorTools();


             this.mountCodeMirror(
             this.editorTools = {
                 textarea.get(0)
                 root: tools,
            ).catch(
                variableSelect,
                 error => {
                 conditionTypeSelect,
                    console.error(
                conditionVariableSelect,
                        'Preload Editor CodeMirror:',
                conditionVariableWrap,
                        error
                insertCondition
                    );
            };
 
            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();


                    this.setStatus(
             return tools;
                        error.message ||
                            'Could not initialize CodeMirror.',
                        'error'
                    );
                }
             );
         },
         },


         reanalyze() {
         updateEditorTools() {
             const source =
             this.updateEditorToolVariables();
                this.getEditorContent();
            this.updateConditionVariableVisibility();
        },


            const content =
        // =========================================================
                this.extractIncludeonly(
        // Syntax highlighting
                    source
        // =========================================================
                );


             const analysis =
        getKnownVariables() {
                 this.analyzePreload(
             const known =
                    content
                 new Set(['PAGE']);
                );


             this.state.preloadContent =
             const fields =
                 source;
                this.state.config &&
                 this.state.config.fields;


             this.state.variables =
             if (
                 analysis.variables;
                 fields &&
 
                typeof fields === 'object'
             this.state.conditions =
             ) {
                 analysis.conditions;
                 Object.keys(fields).forEach(
 
                    name => {
            this.state.config =
                        known.add(
                this.prepareConfigForVariables(
                            String(name).toUpperCase()
                    this.state.config ||
                         );
                         this.emptyConfig(),
                     }
                     analysis
                 );
                 );
            }


             this.analysis =
             return known;
                analysis;
         },
         },


         renderAnalysis(
         createSyntaxDecorations(
             container
             state,
            Decoration
         ) {
         ) {
             container.empty();
             const decorations = [];
            const source =
                state.doc.toString();


             const analysis =
             const knownVariables =
                 this.analysis ||
                 this.getKnownVariables();
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );


             const variableCount =
             const variablePattern =
                 analysis.variables.length;
                 /%%([A-Za-z0-9_-]+)%%/g;


             const conditionCount =
             let match;
                analysis.conditions.length;


             const summary =
             while (
                 $('<div>')
                (
                     .addClass(
                    match =
                        'preload-editor-analysis-summary'
                        variablePattern.exec(source)
                     );
                 ) !== null
            ) {
                const name =
                     match[1].toUpperCase();
 
                const known =
                     knownVariables.has(name);


            summary.append(
                decorations.push(
                $('<span>').text(
                    Decoration.mark({
                    variableCount +
                        class: [
                    ' variable' +
                            'preload-editor-syntax-variable',
                     (
                            known
                         variableCount === 1
                                ? 'preload-editor-syntax-variable-known'
                            ? ''
                                : 'preload-editor-syntax-variable-unknown'
                             : 's'
                        ].join(' ')
                     }).range(
                         match.index,
                        match.index +
                             match[0].length
                     )
                     )
                 ),
                 );
                $('<span>').text(
             }
                    conditionCount +
                    ' condition marker' +
                    (
                        conditionCount === 1
                            ? ''
                            : 's'
                    )
                )
             );


             container.append(
             const conditionPattern =
                 summary
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
            );


             if (
             while (
                 analysis.conditionVariables
                 (
                     .length
                     match =
                        conditionPattern.exec(source)
                ) !== null
             ) {
             ) {
                 container.append(
                 decorations.push(
                     $('<div>')
                     Decoration.mark({
                         .addClass(
                         class:
                             'preload-editor-analysis-note'
                             'preload-editor-syntax-condition'
                        )
                    }).range(
                         .text(
                         match.index,
                            'Condition variables: ' +
                        match.index +
                             analysis
                             match[0].length
                                .conditionVariables
                    )
                                .join(', ')
                        )
                 );
                 );
            }


            const validation =
                const directiveStart =
                 this.validateConfig(
                    match.index +
                     this.state.config,
                    match[0].indexOf('@');
                     analysis
 
                 decorations.push(
                     Decoration.mark({
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveStart +
                            match[1].length +
                            1
                     )
                 );
                 );


            if (
                 const type =
                validation.errors.length
                     match[1].toLowerCase();
            ) {
                 const errors =
                     $('<ul>')
                        .addClass(
                            'preload-editor-validation-errors'
                        );


                 validation.errors.forEach(
                 if (
                     error => {
                     type !== 'if' &&
                        $('<li>')
                    type !== 'elseif'
                            .text(error)
                ) {
                            .appendTo(
                    continue;
                                errors
                }
                            );
 
                    }
                const expression =
                 );
                    match[2];
 
                const expressionStart =
                    match.index +
                    match[0].indexOf(
                        expression
                    );
 
                 const expressionPattern =
                    /[A-Za-z0-9_-]+/g;


                 container.append(
                 let expressionMatch;
                    errors
                );
            }


            if (
                while (
                validation.warnings.length
                    (
            ) {
                        expressionMatch =
                const warnings =
                            expressionPattern.exec(
                    $('<ul>')
                                expression
                        .addClass(
                            )
                            'preload-editor-validation-warnings'
                    ) !== null
                        );
                ) {
                    const name =
                        expressionMatch[0]
                            .toUpperCase();


                validation.warnings.forEach(
                    if (
                    warning => {
                         !knownVariables.has(name)
                         $('<li>')
                    ) {
                            .text(warning)
                        continue;
                            .appendTo(
                                warnings
                            );
                     }
                     }
                );


                container.append(
                    const start =
                     warnings
                        expressionStart +
                 );
                        expressionMatch.index;
 
                    decorations.push(
                        Decoration.mark({
                            class:
                                'preload-editor-syntax-condition-variable'
                        }).range(
                            start,
                            start +
                                expressionMatch[0].length
                        )
                     );
                 }
             }
             }


             if (
             return Decoration.set(
                 !validation.errors.length &&
                 decorations,
                 !validation.warnings.length
                 true
             ) {
             );
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                );
            }
         },
         },


         renderAnalysisBySelector() {
         async installSyntaxHighlighting(view) {
             const container =
             await mw.loader.using(
                this.root &&
                 'ext.CodeMirror.v6.lib'
                 this.root.find(
            );
                    '.preload-editor-analysis'
                );


             if (
             const require =
                 container &&
                 mw.loader.require;
                container.length
            ) {
                this.renderAnalysis(
                    container
                );
            }
        },


        renderVariables(
            const {
             container
                StateField,
        ) {
                StateEffect,
             container.empty();
                EditorView,
                Decoration
             } = require(
                'ext.CodeMirror.v6.lib'
             );


             const analysis =
             const syntaxField =
                 this.analysis ||
                 StateField.define({
                this.analyzePreload(
                     create: state =>
                     this.extractIncludeonly(
                         this.createSyntaxDecorations(
                         this.getEditorContent()
                            state,
                    )
                            Decoration
                );
                        ),


            const variables =
                    update: (
                analysis.variables.slice();
                        decorations,
                        transaction
                    ) =>
                        transaction.docChanged
                            ? this.createSyntaxDecorations(
                                transaction.state,
                                Decoration
                            )
                            : decorations,


            const ordered = [
                    provide: field =>
                 'PAGE'
                        EditorView.decorations.from(
            ];
                            field
                        )
                 });


             variables.forEach(
             view.dispatch({
                 name => {
                 effects:
                     if (
                     StateEffect.appendConfig.of(
                        name !== 'PAGE' &&
                         syntaxField
                        !ordered.includes(
                     )
                            name
            });
                         )
                     ) {
                        ordered.push(name);
                    }
                }
            );


             Object.keys(
             this.editor.syntaxField =
                this.state.config.fields || {}
                 syntaxField;
            ).forEach(
        },
                 name => {
                    const upper =
                        name.toUpperCase();


                    if (
        async mountCodeMirror(textarea) {
                        !ordered.includes(
            return new Promise(
                            upper
                (
                        )
                    resolve,
                    ) {
                     reject
                        ordered.push(
                 ) => {
                            upper
                    let settled = false;
                        );
                     }
                 }
            );


            ordered.forEach(
                    const cleanup = () => {
                name => {
                         mw.hook(
                    const field =
                             'dev.CCM.ready'
                         this.state.config
                         ).remove(onReady);
                             .fields[name] ||
                         this.createDefaultField(
                            name,
                            analysis
                        );


                    this.state.config
                        mw.hook(
                         .fields[name] =
                            'dev.CCM.load'
                        field;
                         ).remove(onLoad);
                    };


                     container.append(
                     const onReady =
                         this.createVariableCard(
                         async cm => {
                            name,
                            if (
                            field,
                                settled ||
                            analysis
                                !cm ||
                        )
                                cm.textarea !== textarea ||
                    );
                                !cm.view
                }
                            ) {
            );
                                return;
        },
                            }


        renderVariablesBySelector() {
                            settled = true;
            const container =
                            cleanup();
                this.root &&
                this.root.find(
                    '.preload-editor-variables'
                );


            if (
                            const view =
                container &&
                                cm.view;
                container.length
            ) {
                this.renderVariables(
                    container
                );
            }
        },


        createVariableCard(
                            const inputHandler =
            name,
                                () => {
            field,
                                    this.state
            analysis
                                        .preloadContent =
        ) {
                                        view.state.doc.toString();
            const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );


            const header =
                                    this.markPreloadDirty();
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );


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


            if (name === 'PAGE') {
                                    this.renderVariablesBySelector();
                header.append(
                                    this.renderVariableConfigurationBySelector();
                    $('<span>')
                                    this.renderAnalysisBySelector();
                        .addClass(
                                    this.updateEditorTools();
                            'preload-editor-badge'
                        )
                        .text(
                            'AUTOMATIC'
                        )
                );
            } else if (
                !analysis.variables
                    .includes(name)
            ) {
                header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text(
                            'UNUSED'
                        )
                );
            }


            card.append(header);
                                    this.saveDraft();
                                };


            const label =
                            view.dom.addEventListener(
                this.textInput(
                                'input',
                    field.label || ''
                                inputHandler
                );
                            );


            label.on(
                            view.dom.classList.add(
                'input.preloadEditor',
                                'preload-editor-codemirror'
                () => {
                            );
                    field.label =
                        label.val();


                    this.markDirty();
                            const wrapper =
                                view.dom.closest(
                                    '.ext-codemirror-wrapper'
                                );


                    this.renderConfigPreviewBySelector();
                            if (wrapper) {
                }
                                wrapper.classList.add(
            );
                                    'preload-editor-codemirror-wrapper'
                                );
                            }


            card.append(
                            this.editor = {
                this.fieldRow(
                                cm,
                    'Label',
                                view,
                    label
                                textarea,
                )
                                inputHandler
            );
                            };


            const description =
                            this.editorView =
                this.textarea(
                                view;
                    field.description || ''
                )
                    .addClass(
                        'preload-editor-small-textarea'
                    );


            description.on(
                            this.state.preloadContent =
                'input.preloadEditor',
                                view.state.doc.toString();
                () => {
                    field.description =
                        description.val();


                    this.markDirty();
                            try {
                                await this.installSyntaxHighlighting(
                                    view
                                );


                    this.renderConfigPreviewBySelector();
                                if (
                }
                                    this.state.dirty
            );
                                ) {
                                    this.saveDraft();
                                }


            card.append(
                                resolve(view);
                this.fieldRow(
                            } catch (error) {
                    'Description',
                                reject(error);
                    description
                            }
                )
                        };
            );


            const types = [
                    const onLoad =
                ['text', 'Text'],
                        loader => {
                ['textarea', 'Textarea'],
                            if (
                ['number', 'Number'],
                                settled ||
                ['boolean', 'Boolean'],
                                typeof loader !==
                ['select', 'Select'],
                                    'function'
                [
                            ) {
                    'multiselect',
                                return;
                    'Multi-select'
                            }
                ],
                ['page', 'Page'],
                ['user', 'User'],
                ['date', 'Date']
            ];


            const typeSelect =
                            try {
                this.select(
                                loader(textarea);
                    types.map(
                            } catch (error) {
                        ([value, label]) => ({
                                settled = true;
                            value,
                                cleanup();
                            label
                                reject(error);
                        })
                            }
                    ),
                         };
                    field.type ||
                         'text'
                );


            typeSelect.on(
                    mw.hook(
                'change.preloadEditor',
                        'dev.CCM.ready'
                () => {
                    ).add(onReady);
                    field.type =
                        typeSelect.val();


                     this.markDirty();
                     mw.hook(
 
                        'dev.CCM.load'
                    card.replaceWith(
                     ).add(onLoad);
                        this.createVariableCard(
                            name,
                            field,
                            analysis
                        )
                     );
 
                    this.renderConfigPreviewBySelector();
                 }
                 }
             );
             );
        },


            card.append(
        // =========================================================
                this.fieldRow(
        // Main UI
                    'Type',
        // =========================================================
                    typeSelect
                )
            );


             if (name === 'PAGE') {
        init() {
                 field.type = 'page';
             const root =
                $('#mw-content-text');
 
            if (!root.length) {
                 return;
            }


                 const required =
            this.root =
                     this.checkbox(
                 $('<div>')
                         field.required !== false
                     .attr(
                         'id',
                        'preload-editor'
                    )
                    .addClass(
                        'preload-editor'
                     );
                     );


                required.on(
            root.append(this.root);
                    'change.preloadEditor',
 
                    () => {
            this.renderShell();
                        field.required =
                            required.prop(
                                'checked'
                            );


                        this.markDirty();
            this.restoreDraft();


                         this.renderConfigPreviewBySelector();
            window.addEventListener(
                'beforeunload',
                () => {
                    if (this.state.dirty) {
                         this.saveDraft();
                     }
                     }
                 );
                 }
            );
        },
                renderShell() {
            this.destroyEditor();
            this.root.empty();
 
            const loader =
                this.createSection('Preload');


                 card.append(
            const titleInput =
                     this.fieldRow(
                 this.textInput(
                        'Required',
                     this.state.preloadTitle,
                        required
                    'Template:Example/Preload'
                     )
                ).attr(
                    'id',
                     'preload-editor-title-input'
                 );
                 );


                const namespace =
            titleInput.on(
                    this.select(
                'input.preloadEditor',
                        [
                () => {
                            {
                    this.state.preloadTitle =
                                value: 'Main',
                        String(
                                label: 'Main'
                             titleInput.val() || ''
                            },
                        ).trim();
                            {
                                value: 'Template',
                                label:
                                    'Template'
                            },
                            {
                                value: 'File',
                                label: 'File'
                            },
                            {
                                value:
                                    'Category',
                                label:
                                    'Category'
                            },
                             {
                                value: 'User',
                                label: 'User'
                            }
                        ],
                        field.namespace ||
                            'Main'
                    );


                namespace.on(
                    if (
                    'change.preloadEditor',
                        this.state.dirty &&
                     () => {
                        this.state.mode === 'new'
                         field.namespace =
                     ) {
                            namespace.val();
                         this.saveDraft();
                    }
                }
            );


                         this.markDirty();
            const loadButton =
                this.createButton(
                    'Load',
                    'preload-editor-button-primary',
                    () =>
                         this.loadExisting(
                            titleInput.val()
                        )
                );


                        this.renderConfigPreviewBySelector();
            const newButton =
                     }
                this.createButton(
                     'New',
                    '',
                    () => this.startNew()
                 );
                 );


                 card.append(
            const sampleButton =
                     this.fieldRow(
                 this.createButton(
                        'Namespace',
                     'Load Sample Page',
                        namespace
                    '',
                     )
                     () => this.loadSamplePage()
                 );
                 );


                 const allowNew =
            loader.append(
                     this.checkbox(
                 $('<div>')
                         field['allow-new'] ===
                     .addClass(
                            true
                         'preload-editor-toolbar'
                     );
                     )
 
                    .append(
                allowNew.on(
                        titleInput,
                    'change.preloadEditor',
                        loadButton,
                    () => {
                         newButton,
                         field['allow-new'] =
                        sampleButton
                            allowNew.prop(
                    )
                                'checked'
            );
                            );


                        this.markDirty();
            this.root.append(loader);


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


                card.append(
            this.root.append(
                    this.fieldRow(
                this.workspace
                        'Allow new page',
            );
                        allowNew
                    )
                );


                 return card;
            /*
            }
            * Fixed footer
            *
            * Local draft status, normal status messages,
            * and save actions live here.
            */
            const footer =
                 $('<div>')
                    .addClass(
                        'preload-editor-footer'
                    );


             const required =
             this.localDraftStatus =
                 this.checkbox(
                 $('<div>')
                     field.required === true
                    .addClass(
                );
                        'preload-editor-local-draft-status is-empty'
                    )
                     .text(
                        'No local draft'
                    );


             required.on(
             this.status =
                 'change.preloadEditor',
                 $('<div>')
                () => {
                     .addClass(
                     field.required =
                         'preload-editor-status'
                         required.prop(
                    );
                            'checked'
                        );


                    this.markDirty();
            footer.append(
 
                this.localDraftStatus,
                    this.renderConfigPreviewBySelector();
                 this.status
                 }
             );
             );


             card.append(
             this.root.append(
                 this.fieldRow(
                 footer
                    'Required',
                    required
                )
             );
             );


             const placeholder =
             this.updateLocalDraftIndicator();
                this.textInput(
                    field.placeholder || ''
                );


             placeholder.on(
             this.renderWelcome();
                'input.preloadEditor',
        },
                () => {
                    const value =
                        placeholder.val();


                    if (value) {
        renderWelcome() {
                        field.placeholder =
            this.workspace.empty();
                            value;
                    } else {
                        delete field.placeholder;
                    }


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


                    this.renderConfigPreviewBySelector();
            $('<p>')
                 }
                 .text(
            );
                     'Load an existing preload, create a new one, or start from a sample wiki page.'
 
            card.append(
                this.fieldRow(
                     'Placeholder',
                    placeholder
                 )
                 )
            );
                .appendTo(section);


             const defaultInput =
             const tips =
                 this.textInput(
                 $('<ul>')
                     field.default === undefined
                     .addClass(
                         ? ''
                         'preload-editor-tips'
                        : field.default
                    );
                );


             defaultInput.on(
             [
                 'input.preloadEditor',
                'Existing preload: loads the preload and its config.json if available.',
                 () => {
                 'New preload: starts with the standard Preload Template structure.',
                     const value =
                 'Sample page: loads normal wiki page content so you can turn it into a preload.'
                        defaultInput.val();
            ].forEach(
 
                text => {
                    if (value) {
                     $('<li>')
                         field.default =
                         .text(text)
                            value;
                        .appendTo(tips);
                    } else {
                        delete field.default;
                    }
 
                    this.markDirty();
 
                    this.renderConfigPreviewBySelector();
                 }
                 }
             );
             );


             card.append(
             section.append(tips);
                 this.fieldRow(
            this.workspace.append(section);
                    'Default',
        },
                    defaultInput,
 
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
        setStatus(
                 )
            message,
            type
        ) {
            if (!this.status) {
                 return;
            }
 
            this.status.removeClass(
                'is-error',
                'is-success',
                'is-warning',
                 'is-loading'
             );
             );


             if (
             if (type) {
                field.type ===
                 this.status.addClass(
                'textarea'
                    'is-' + type
            ) {
                );
                 const maxRows =
            }
                    this.textInput(
                        field.maxRows || ''
                    ).attr(
                        'type',
                        'number'
                    );


                maxRows.on(
            this.status.text(
                    'input.preloadEditor',
                message || ''
                    () => {
            );
                        const value =
        },
                            parseInt(
                                maxRows.val(),
                                10
                            );


                        if (
        // =========================================================
                            Number.isFinite(
        // Load / create
                                value
        // =========================================================
                            ) &&
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }


                        this.markDirty();
        async loadExisting(title) {
            title =
                String(title || '').trim();


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


                 card.append(
                 return;
                    this.fieldRow(
                        'Maximum rows',
                        maxRows
                    )
                );
             }
             }


             if (
             this.setStatus(
                 field.type === 'select' ||
                 'Loading preload...',
                 field.type ===
                 'loading'
                    'multiselect'
             );
             ) {
                this.renderSelectConfiguration(
                    card,
                    field
                );
            }


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


        renderSelectConfiguration(
                if (!page) {
            card,
                    const suggestions =
            field
                        await this.findSimilarPreloads(
        ) {
                            title
            const source =
                        );
                this.textInput(
                    field.source || '',
                    'category:Example'
                );


            source.on(
                    this.renderMissingPage(
                'input.preloadEditor',
                        title,
                () => {
                        suggestions
                     const value =
                     );
                        source.val();


                     if (value) {
                     this.setStatus(
                         field.source =
                         'Preload not found.',
                            value;
                        'warning'
                     } else {
                     );
                        delete field.source;
                    }


                     this.markDirty();
                     return;
 
                    this.renderConfigPreviewBySelector();
                 }
                 }
            );


            card.append(
                 await this.loadDefinition(
                 this.fieldRow(
                     page.title,
                     'Source',
                     page.content
                     source,
                 );
                    'Currently supported source format: category:Category Name'
                 )
            );


            const sourcePrefix =
                 this.setStatus(
                 this.textInput(
                     'Preload loaded.',
                     field['source-prefix'] ||
                    'success'
                        ''
                );
            } catch (error) {
                console.error(
                    'Preload Editor:',
                    error
                 );
                 );


            sourcePrefix.on(
                this.setStatus(
                'input.preloadEditor',
                    error.message ||
                 () => {
                        'Could not load preload.',
                    const value =
                    'error'
                        sourcePrefix.val();
                 );
            }
        },


                    if (value) {
        async findSimilarPreloads(title) {
                        field[
            let query =
                            'source-prefix'
                title
                        ] = value;
                     .replace(
                     } else {
                         /\/Preload$/i,
                         delete field[
                        ''
                            'source-prefix'
                    )
                        ];
                     .trim();
                     }


                    this.markDirty();
            if (!query) {
                query = title;
            }


                     this.renderConfigPreviewBySelector();
            try {
                }
                const results =
            );
                     await this.searchPages(query);


             card.append(
                return results.filter(
                 this.fieldRow(
                    result =>
                     'Source prefix',
                        /\/Preload$/i.test(
                     sourcePrefix,
                            result.title
                    'Only source values beginning with this text are included.'
                        )
                 )
                );
            );
             } catch (error) {
                 console.error(
                     'Preload Editor search:',
                     error
                 );


            const excludeCategory =
                 return [];
                 this.textInput(
            }
                    field.exclude &&
        },
                    field.exclude.category
                        ? field.exclude.category
                        : ''
                );


            excludeCategory.on(
        renderMissingPage(
                'input.preloadEditor',
            title,
                () => {
            suggestions
                    const value =
        ) {
                        excludeCategory.val();
            this.workspace.empty();


                    if (value) {
            const section =
                        field.exclude =
                this.createSection(
                            Object.assign(
                     'Preload not found'
                                {},
                );
                                field.exclude || {},
                                {
                                    category:
                                        value
                                }
                            );
                     } else if (
                        field.exclude
                    ) {
                        delete field.exclude
                            .category;


                        if (
            $('<p>')
                            !Object.keys(
                .text(
                                field.exclude
                    'No preload exists at "' +
                            ).length
                    title +
                        ) {
                    '".'
                            delete field.exclude;
                )
                        }
                .appendTo(section);
                    }


                     this.markDirty();
            if (suggestions.length) {
                $('<h3>')
                    .text('Similar preloads')
                     .appendTo(section);


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


             card.append(
                suggestions.forEach(
                 this.fieldRow(
                    result => {
                     'Exclude category',
                        list.append(
                     excludeCategory
                            this.createButton(
                                result.title,
                                'preload-editor-suggestion',
                                () =>
                                    this.loadExisting(
                                        result.title
                                    )
                            )
                        );
                    }
                );
 
                section.append(list);
            }
 
             section.append(
                 this.createButton(
                     'Create this preload',
                    'preload-editor-button-primary',
                     () =>
                        this.startNew(title)
                 )
                 )
             );
             );


             card.append(
             this.workspace.append(section);
                this.createTransformControls(
        },
                    field,
                    'value',
                    'Value transformation'
                ),
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );


             const optionsValue =
        async startNew(title) {
                 Array.isArray(
             title =
                    field.options
                 String(title || '').trim();
                )
                    ? field.options
                        .map(option => {
                            if (
                                typeof option ===
                                'string'
                            ) {
                                return option;
                            }


                            return (
            this.state = {
                                String(
                preloadTitle: title,
                                    option.value ||
                preloadSource: '',
                                    ''
                preloadContent:
                                ) +
                    this.defaults.preload,
                                ' | ' +
                                String(
                                    option.label ||
                                    option.value ||
                                    ''
                                )
                            );
                        })
                        .join('\n')
                    : '';


            const options =
                 config:
                 this.textarea(
                     this.emptyConfig(),
                    optionsValue
                )
                     .addClass(
                        'preload-editor-small-textarea'
                    );


            options.on(
                 variables: ['PAGE'],
                 'input.preloadEditor',
                 conditions: [],
                 () => {
                selectedVariable: 'PAGE',
                    const lines =
                deletedVariables: [],
                        options
                mode: 'new',
                            .val()
                dirty: true,
                            .split('\n')
                dirtyPreload: true,
                            .map(
                dirtyConfig: true,
                                line =>
                                    line.trim()
                            )
                            .filter(
                                Boolean
                            );


                    if (!lines.length) {
                localDraftSavedAt: null,
                        delete field.options;
                localDraftAvailable: false
                    } else {
            };
                        field.options =
                            lines.map(
                                line => {
                                    const parts =
                                        line.split(
                                            /\s*\|\s*/
                                        );


                                    if (
            this.saveDraft();
                                        parts.length ===
            this.renderEditor();
                                        1
                                    ) {
                                        return parts[0];
                                    }


                                    return {
            this.setStatus(
                                        value:
                'Creating a new preload.',
                                            parts[0],
                'success'
                                        label:
                                            parts
                                                .slice(
                                                    1
                                                )
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }
 
                    this.markDirty();
 
                    this.renderConfigPreviewBySelector();
                }
             );
             );
        },


             card.append(
        async loadSamplePage() {
                 this.fieldRow(
             const result =
                     'Manual options',
                 await this.createPromptDialog(
                     options,
                     'Load Sample Page',
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                     'Enter the title of a normal wiki page to use as the starting point.'
                 )
                );
 
            if (!result) {
                return;
            }
 
            this.setStatus(
                'Loading sample page...',
                 'loading'
             );
             );
        },


        createTransformControls(
             try {
             field,
                const page =
            property,
                    await this.getPage(result);
            title
 
        ) {
                 if (!page) {
            const wrapper =
                     throw new Error(
                 $('<div>')
                         'Sample page not found: ' +
                     .addClass(
                        result
                         'preload-editor-transform'
                     );
                     );
                }


            $('<h4>')
                 const sample =
                 .text(title)
                     this.convertSampleToPreload(
                .appendTo(wrapper);
                        page.content
 
                     );
            const existing =
                field[property] &&
                typeof field[property] ===
                     'object'
                    ? field[property]
                     : {};


            const transform =
                 this.state = {
                 this.select(
                     preloadTitle: '',
                     [
                    preloadSource: '',
                        {
                    preloadContent: sample,
                            value: '',
                            label:
                                'No transformation'
                        },
                        {
                            value:
                                'remove-prefix',
                            label:
                                'Remove prefix'
                        },
                        {
                            value:
                                'remove-suffix',
                            label:
                                'Remove suffix'
                        },
                        {
                            value: 'replace',
                            label:
                                'Replace'
                        }
                    ],
                    existing.transform ||
                        ''
                );


            const prefix =
                    config:
                 this.textInput(
                        this.emptyConfig(),
                     existing.prefix || ''
 
                    variables: ['PAGE'],
                    conditions: [],
                    selectedVariable: 'PAGE',
                    deletedVariables: [],
                    mode: 'new',
                    dirty: true,
                    dirtyPreload: true,
                    dirtyConfig: true,
 
                    localDraftSavedAt: null,
                    localDraftAvailable: false
                };
 
                this.reanalyze();
                this.saveDraft();
                this.renderEditor();
 
                 this.setStatus(
                     'Sample page loaded.',
                    'success'
                );
            } catch (error) {
                console.error(
                    'Preload Editor sample:',
                    error
                 );
                 );


            const suffix =
                 this.setStatus(
                 this.textInput(
                     error.message ||
                     existing.suffix || ''
                        'Could not load sample page.',
                    'error'
                 );
                 );
            }
        },


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


             const to =
             const include =
                 this.textInput(
                 content.match(
                     existing.to || ''
                     /<includeonly>([\s\S]*?)<\/includeonly>/i
                 );
                 );


             const controls =
             if (include) {
                 $('<div>')
                 content =
                     .addClass(
                     include[1].trim();
                        'preload-editor-transform-controls'
            }
                    );


             const prefixRow =
             content =
                 this.fieldRow(
                 content
                    'Prefix',
                    .replace(
                     prefix
                        /<noinclude>[\s\S]*?<\/noinclude>/gi,
                );
                        ''
                     )
                    .trim();


             const suffixRow =
             return (
                 this.fieldRow(
                '<includeonly>\n' +
                    'Suffix',
                 content +
                    suffix
                '\n</includeonly>\n' +
                );
                '<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
            );
        },


             const fromRow =
        async loadDefinition(
                 this.fieldRow(
            title,
                    'From',
            source
                    from
        ) {
                );
             const content =
                 this.extractIncludeonly(source);


             const toRow =
            let config =
                 this.fieldRow(
                this.emptyConfig();
                     'To',
 
                     to
             const configTitle =
                 );
                 title.replace(
                     /\/+$/,
                     ''
                 ) +
                '/config.json';


             const renderInputs = () => {
             const configPage =
                 const type =
                 await this.getPage(configTitle);
                    transform.val();


                 prefixRow.toggle(
            if (configPage) {
                     type ===
                 try {
                         'remove-prefix'
                    config =
                );
                        JSON.parse(
                            configPage.content
                        );
                } catch (error) {
                     throw new Error(
                         'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
            }


                 suffixRow.toggle(
            const analysis =
                    type ===
                 this.analyzePreload(content);
                        'remove-suffix'
                );


                fromRow.toggle(
            this.state.deletedVariables = [];
                    type === 'replace'
                );


                 toRow.toggle(
            config =
                     type === 'replace'
                 this.prepareConfigForVariables(
                     config,
                    analysis
                 );
                 );
            };


             const update = () => {
             this.state = {
                 const type =
                 preloadTitle: title,
                    transform.val();
                preloadSource: source,
 
                preloadContent: source,
                 let object = null;
                config,
 
                 variables:
                    analysis.variables,


                 if (
                 conditions:
                     type ===
                     analysis.conditions,
                    'remove-prefix'
 
                 ) {
                 selectedVariable:
                     if (prefix.val()) {
                     analysis.variables.includes('PAGE')
                        object = {
                         ? 'PAGE'
                            transform: type,
                         : (
                            prefix:
                             analysis.variables[0] ||
                                prefix.val()
                            'PAGE'
                         };
                         ),
                    }
                } else if (
                    type ===
                    'remove-suffix'
                ) {
                    if (suffix.val()) {
                         object = {
                            transform: type,
                             suffix:
                                suffix.val()
                        };
                    }
                } else if (
                    type ===
                    'replace'
                ) {
                    if (from.val()) {
                         object = {
                            transform: type,
                            from:
                                from.val(),
                            to:
                                to.val()
                        };
                    }
                }


                 if (object) {
                 deletedVariables: [],
                    field[property] =
                        object;
                } else {
                    delete field[property];
                }


                 this.markDirty();
                 mode: 'edit',
                dirty: false,
                dirtyPreload: false,
                dirtyConfig: false,


                 this.renderConfigPreviewBySelector();
                 localDraftSavedAt: null,
                localDraftAvailable: false
             };
             };


             transform.on(
             this.clearDraft(title);
                'change.preloadEditor',
            this.renderEditor();
                () => {
        },
                    update();
                    renderInputs();
                }
            );


            [
        // =========================================================
                prefix,
        // Editor
                suffix,
        // =========================================================
                from,
                to
            ].forEach(
                input => {
                    input.on(
                        'input.preloadEditor',
                        update
                    );
                }
            );


            controls.append(
        renderEditor() {
                this.fieldRow(
            this.destroyEditor();
                    'Transformation',
             this.workspace.empty();
                    transform
                ),
                prefixRow,
                suffixRow,
                fromRow,
                toRow
             );


             renderInputs();
             const layout =
                $('<div>')
                    .addClass(
                        'preload-editor-layout'
                    );


             wrapper.append(
             const left =
                controls
                $('<div>')
            );
                    .addClass(
                        'preload-editor-left'
                    );


             return wrapper;
             const right =
        },
                $('<div>')
                    .addClass(
                        'preload-editor-right'
                    );


        // Config output
             const editorSection =
 
                 this.createSection(
        getCleanConfig() {
                     'Preload',
             const config =
                     'preload-editor-main-section'
                 JSON.parse(
                     JSON.stringify(
                        this.state.config
                     )
                 );
                 );


             const fields =
             const meta =
                 config.fields || {};
                 $('<div>')
                    .addClass(
                        'preload-editor-meta'
                    );


             const orderedFields = {};
             $('<strong>')
                .text(
                    this.state.preloadTitle ||
                    'New preload'
                )
                .appendTo(meta);


             if (fields.PAGE) {
             if (
                 orderedFields.PAGE =
                this.state.mode === 'new'
                     fields.PAGE;
            ) {
             }
                 $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                     .appendTo(meta);
             }


             Object.keys(fields)
             editorSection.append(meta);
                 .filter(
            editorSection.append(
                    name =>
                 this.renderEditorTools()
                        name.toUpperCase() !==
            );
                        'PAGE'
 
            const textarea =
                this.textarea(
                    this.state.preloadContent
                 )
                 )
                .forEach(
                    .attr(
                    name => {
                        'id',
                         orderedFields[name] =
                         'preload-editor-content'
                            fields[name];
                    )
                     }
                     .addClass(
                );
                        'preload-editor-content'
                    );


             config.fields =
             const editorMount =
                 orderedFields;
                 $('<div>')
                    .addClass(
                        'preload-editor-codemirror-mount'
                    )
                    .append(textarea);


             return config;
             editorSection.append(editorMount);
        },
            left.append(editorSection);


        getConfigJSON() {
             const analysisSection =
             return JSON.stringify(
                 this.createSection('Analysis');
                 this.getCleanConfig(),
                null,
                4
            );
        },


        renderConfigPreview(
             const analysisContainer =
             container
                $('<div>')
        ) {
                    .addClass(
            if (!container) {
                        'preload-editor-analysis'
                return;
                    );
            }


             container.text(
             analysisSection.append(
                 this.getConfigJSON()
                 analysisContainer
             );
             );
        },


        renderConfigPreviewBySelector() {
             const variablesSection =
             const preview =
                 this.createSection('Variables');
                 this.root &&
                this.root.find(
                    '.preload-editor-config-preview'
                );


             if (
             const detectVariablesButton =
                preview &&
                this.createButton(
                preview.length
                    'Detect variables',
            ) {
                    'preload-editor-button-primary preload-editor-detect-variables',
                this.renderConfigPreview(
                    () =>
                    preview
                        this.detectVariables()
                 );
                 );
            }


             this.renderAnalysisBySelector();
             const detectHelp =
        },
                $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-detect-variables-help'
                    )
                    .text(
                        'Refresh the variable list after editing the preload.'
                    );


        markDirty() {
            variablesSection.append(
            this.state.dirty = true;
                $('<div>')
        },
                    .addClass(
 
                        'preload-editor-variable-detect'
        // Save
                    )
                    .append(
                        detectVariablesButton,
                        detectHelp
                    )
            );


        async savePreloadOnly() {
             const variableContainer =
             let title =
                 $('<div>')
                 String(
                     .addClass(
                    this.state.preloadTitle ||
                         'preload-editor-variables'
                        ''
                ).trim();
 
            if (!title) {
                const result =
                     await this.createPromptDialog(
                         'Preload title',
                        'Enter the full title for the preload.'
                     );
                     );


                 if (!result) {
            variablesSection.append(
                    return;
                 variableContainer
                }
            );


                title = result;
            const configurationSection =
                 this.state.preloadTitle =
                 this.createSection(
                     title;
                     'Variable configuration'
            }
                );


             this.state.preloadContent =
             /*
                this.getEditorContent();
            * Import button lives at the top of the
            * variable configuration section so the
            * workflow is always easy to find.
            */
            const importDefinitionsButton =
                this.createButton(
                    'Import definitions',
                    'preload-editor-button-primary preload-editor-import-definitions',
                    () =>
                        this.importVariableDefinitions()
                );


             const validation =
             const importHelp =
                 this.validateBeforeSave();
                 $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-import-definitions-help'
                    )
                    .text(
                        'Import variable definitions from another preload config.json. Existing definitions can be replaced.'
                    );


             if (
             const importControls =
                validation.errors.length
                $('<div>')
            ) {
                    .addClass(
                this.showValidationErrors(
                        'preload-editor-import-definition-controls'
                     validation
                    )
                );
                    .append(
                return;
                        importDefinitionsButton,
            }
                        importHelp
                     );


             this.setStatus(
             configurationSection.append(
                 'Saving preload...',
                 importControls
                'loading'
             );
             );


             try {
             const configurationContainer =
                 const exists =
                 $('<div>')
                     await this.pageExists(
                     .addClass(
                         title
                         'preload-editor-variable-configuration'
                     );
                     );


                await this.savePage(
            configurationSection.append(
                    title,
                configurationContainer
                    this.state.preloadContent,
            );
                    exists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !exists
                );


                this.state.mode =
            const configSection =
                    'edit';
                 this.createSection(
 
                     'Configuration'
                 this.state.dirty =
                    false;
 
                this.setStatus(
                     'Preload saved successfully.',
                    'success'
                 );
                 );


                 mw.notify(
            const configHelp =
                    'Preload saved.',
                 $('<div>')
                     {
                    .addClass(
                         type: 'success'
                        'preload-editor-help'
                     }
                     )
                );
                    .text(
            } catch (
                         'Review the exact config.json that will be uploaded to the wiki.'
                error
                     );
             ) {
 
                 console.error(
             const reviewConfigButton =
                     'Preload Editor save:',
                 this.createButton(
                     error
                     'Review config.json',
                     '',
                    () =>
                        this.createConfigReviewDialog()
                 );
                 );


                this.setStatus(
            configSection.append(
                    error.message ||
                configHelp,
                        'Could not save preload.',
                reviewConfigButton
                    'error'
            );
                );


                mw.notify(
            right.append(
                    error.message ||
                analysisSection,
                        'Could not save preload.',
                variablesSection,
                    {
                configurationSection,
                        type: 'error'
                configSection
                    }
            );
                );
            }
        },


        async saveAll() {
            layout.append(
            let title =
                left,
                 String(
                 right
                    this.state.preloadTitle ||
            );
                        ''
                ).trim();


             if (!title) {
             const saveBar =
                const result =
                $('<div>')
                     await this.createPromptDialog(
                     .addClass(
                         'Preload title',
                         'preload-editor-save-bar'
                        'Enter the full title for the preload.'
                     );
                     );


                 if (!result) {
            saveBar.append(
                    return;
                 this.createButton(
                 }
                    'Save Preload',
                    '',
                    () =>
                        this.savePreloadOnly()
                 ),


                 title = result;
                 this.createButton(
                this.state.preloadTitle =
                    'Save Config',
                    title;
                    '',
            }
                    () =>
                        this.saveConfigOnly()
                ),


            this.state.preloadContent =
                this.createButton(
                this.getEditorContent();
                    'Save',
                    'preload-editor-button-primary',
                    () =>
                        this.saveAll()
                )
            );


             const validation =
             this.root
                 this.validateBeforeSave();
                .find('.preload-editor-footer')
                 .append(saveBar);


             if (
             this.workspace.append(
                 validation.errors.length
                 layout
             ) {
             );
                this.showValidationErrors(
                    validation
                );
                return;
            }


             const configTitle =
             this.reanalyze();
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';


             this.setStatus(
             this.renderVariables(
                 'Saving preload and config...',
                 variableContainer
                'loading'
             );
             );


             try {
             this.renderAnalysis(
                const preloadExists =
                analysisContainer
                    await this.pageExists(
            );
                        title
                    );


                await this.savePage(
            this.renderVariableConfiguration(
                    title,
                configurationContainer
                    this.state.preloadContent,
            );
                    preloadExists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !preloadExists
                );


                const configExists =
            this.updateEditorTools();
                    await this.pageExists(
            this.updateLocalDraftIndicator();
                        configTitle
                    );


                await this.savePage(
            this.mountCodeMirror(
                    configTitle,
                textarea.get(0)
                    this.getConfigJSON(),
            ).catch(
                     configExists
                error => {
                         ? 'Updated preload config with Preload Editor'
                     console.error(
                         : 'Created preload config with Preload Editor',
                         'Preload Editor CodeMirror:',
                     !configExists
                         error
                 );
                    );
 
                    this.setStatus(
                        error.message ||
                            'Could not initialize CodeMirror.',
                        'error'
                     );
                 }
            );
        },


                this.state.mode =
        // =========================================================
                    'edit';
        // Variable detection
        // =========================================================


                 this.state.dirty =
        detectVariables() {
                    false;
            const source =
                 this.getEditorContent();


                this.state.preloadTitle =
            this.state.preloadContent =
                    title;
                source;


                 this.setStatus(
            const content =
                     'Preload and config saved successfully.',
                 this.extractIncludeonly(
                    'success'
                     source
                 );
                 );


                 mw.notify(
            const analysis =
                     'Preload and config saved.',
                 this.analyzePreload(
                    {
                     content
                        type: 'success'
                    }
                 );
                 );
             } catch (
 
                 error
             /*
            * If a variable was previously deleted from
            * the configuration, detecting the current
            * preload again should allow it to come back.
            */
            if (
                 Array.isArray(
                    this.state.deletedVariables
                )
             ) {
             ) {
                 console.error(
                 const detected =
                    'Preload Editor save:',
                    new Set(
                     error
                        analysis.variables
                );
                            .map(
                                name =>
                                    String(
                                        name
                                    ).toUpperCase()
                            )
                     );


                 this.setStatus(
                 this.state.deletedVariables =
                     error.message ||
                     this.state.deletedVariables
                         'Could not save preload/config.',
                         .filter(
                    'error'
                            name =>
                );
                                !detected.has(
 
                                    String(
                mw.notify(
                                        name
                    error.message ||
                                    ).toUpperCase()
                        'Could not save preload/config.',
                                )
                    {
                         );
                         type: 'error'
            } else {
                    }
                 this.state.deletedVariables = [];
                 );
             }
             }
        },


        validateBeforeSave() {
            this.markDirty();
            const errors = [];
            const warnings = [];


             const title =
             this.reanalyze();
                String(
                    this.state.preloadTitle ||
                        ''
                ).trim();


             if (!title) {
             this.renderVariablesBySelector();
                errors.push(
            this.renderVariableConfigurationBySelector();
                    'A preload title is required.'
            this.renderAnalysisBySelector();
                );
            this.updateEditorTools();
             }
 
             this.saveDraft();


             if (
             this.setStatus(
                 title &&
                 'Variables detected from the current preload.',
                !/\/Preload$/i.test(
                 'success'
                    title
            );
                )
        },
            ) {
                 warnings.push(
                    'The title does not end with "/Preload".'
                );
            }


             const content =
        reanalyze() {
             const source =
                 this.getEditorContent();
                 this.getEditorContent();


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


             if (!content.trim()) {
             const analysis =
                errors.push(
                 this.analyzePreload(
                    'The preload cannot be empty.'
                );
            }
 
            if (
                 !/<includeonly>/i.test(
                     content
                     content
                )
            ) {
                warnings.push(
                    'The preload does not contain an <includeonly> section.'
                 );
                 );
            }


             if (
            this.state.preloadContent =
                 !/<noinclude>/i.test(
                source;
                     content
 
            this.state.variables =
                analysis.variables;
 
            this.state.conditions =
                analysis.conditions;
 
            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );
 
            this.analysis =
                analysis;
 
            const available =
                ['PAGE'].concat(
                    analysis.variables.filter(
                        name =>
                            name !== 'PAGE'
                    )
                );
 
             if (
                 !available.includes(
                     this.state.selectedVariable
                 )
                 )
             ) {
             ) {
                 warnings.push(
                 this.state.selectedVariable =
                     'The preload does not contain a <noinclude> section.'
                    available[0] ||
                );
                     'PAGE';
             }
             }
        },
        // =========================================================
        // Analysis
        // =========================================================
        renderAnalysis(container) {
            container.empty();


             const analysis =
             const analysis =
                this.analysis ||
                 this.analyzePreload(
                 this.analyzePreload(
                     this.extractIncludeonly(
                     this.extractIncludeonly(
                         content
                         this.getEditorContent()
                     )
                     )
                 );
                 );


             const configValidation =
             const variableCount =
                 this.validateConfig(
                 analysis.variables.length;
                    this.state.config,
                    analysis
                );


             errors.push(
             const conditionCount =
                 ...configValidation.errors
                analysis.conditions.length;
             );
 
            const configuredCount =
                Object.keys(
                    this.state.config &&
                    this.state.config.fields
                        ? this.state.config.fields
                        : {}
                ).length;
 
            const summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );
 
            const createMetric =
                (
                    value,
                    label
                 ) =>
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-metric'
                        )
                        .append(
                            $('<strong>')
                                .addClass(
                                    'preload-editor-analysis-value'
                                )
                                .text(value),
 
                            $('<span>')
                                .addClass(
                                    'preload-editor-analysis-label'
                                )
                                .text(label)
                        );
 
             summary.append(
                createMetric(
                    variableCount,
                    variableCount === 1
                        ? 'variable'
                        : 'variables'
                ),
 
                createMetric(
                    conditionCount,
                    conditionCount === 1
                        ? 'condition marker'
                        : 'condition markers'
                ),


            warnings.push(
                createMetric(
                 ...configValidation.warnings
                    configuredCount,
                    configuredCount === 1
                        ? 'configured field'
                        : 'configured fields'
                 )
             );
             );


             let depth = 0;
             container.append(summary);


             analysis.conditions.forEach(
             if (
                condition => {
                analysis.conditionVariables.length
                     if (
            ) {
                         condition.type ===
                container.append(
                         'if'
                    $('<div>')
                     ) {
                        .addClass(
                         depth++;
                            'preload-editor-analysis-note'
                     } else if (
                        )
                         condition.type ===
                        .text(
                         'endif'
                            'Condition variables: ' +
                            analysis.conditionVariables.join(', ')
                        )
                );
            }
 
            const validation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );
 
            if (validation.errors.length) {
                const errors =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-errors'
                        );
 
                validation.errors.forEach(
                    error => {
                        $('<li>')
                            .text(error)
                            .appendTo(errors);
                     }
                );
 
                container.append(errors);
            }
 
            if (validation.warnings.length) {
                const warnings =
                    $('<ul>')
                         .addClass(
                            'preload-editor-validation-warnings'
                        );
 
                validation.warnings.forEach(
                    warning => {
                         $('<li>')
                            .text(warning)
                            .appendTo(warnings);
                     }
                );
 
                container.append(warnings);
            }
 
            if (
                !validation.errors.length &&
                !validation.warnings.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                         )
                );
            }
        },
 
        renderAnalysisBySelector() {
            const container =
                this.root &&
                this.root.find(
                     '.preload-editor-analysis'
                );
 
            if (
                container &&
                container.length
            ) {
                this.renderAnalysis(container);
            }
        },
 
        // =========================================================
        // Variables
        // =========================================================
 
        renderVariables(container) {
            container.empty();
 
            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                         this.getEditorContent()
                    )
                );
 
            const variables =
                analysis.variables.slice();
 
            const ordered =
                ['PAGE'];
 
            variables.forEach(
                name => {
                    if (
                         name !== 'PAGE' &&
                        !ordered.includes(name)
                     ) {
                     ) {
                         depth--;
                        ordered.push(name);
                    }
                }
            );
 
            const configuredFields =
                this.state.config &&
                this.state.config.fields
                    ? Object.keys(
                        this.state.config.fields
                    )
                    : [];
 
            configuredFields.forEach(
                name => {
                    const upper =
                        name.toUpperCase();
 
                    if (
                        upper !== 'PAGE' &&
                        !ordered.includes(upper)
                    ) {
                        ordered.push(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);
                    }
                }
            );
 
            const list =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-list'
                    );
 
            ordered.forEach(
                name => {
                    const isSelected =
                        name ===
                        this.state.selectedVariable;
 
                    const isUsed =
                        analysis.variables.includes(
                            name
                        );
 
                    const isConfigured =
                        !!(
                            this.state.config &&
                            this.state.config.fields &&
                            this.state.config.fields[name]
                        );
 
                    const item =
                        $('<button>')
                            .attr({
                                type: 'button',
                                'data-variable':
                                    name
                            })
                            .addClass(
                                'preload-editor-variable-item'
                            )
                            .toggleClass(
                                'is-selected',
                                isSelected
                            )
                            .on(
                                'click.preloadEditor',
                                () =>
                                    this.selectVariable(
                                        name
                                    )
                            );
 
                    $('<span>')
                        .addClass(
                            'preload-editor-variable-name'
                        )
                        .text(name)
                        .appendTo(item);
 
                    if (name === 'PAGE') {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge'
                                )
                                .text('AUTOMATIC')
                        );
                    } else if (!isConfigured) {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge is-warning'
                                )
                                .text(
                                    isUsed
                                        ? 'NOT CONFIGURED'
                                        : 'DELETED'
                                )
                        );
                    } else if (!isUsed) {
                        item.append(
                            $('<span>')
                                .addClass(
                                    'preload-editor-badge is-warning'
                                )
                                .text('UNUSED')
                        );
                    }
 
                    list.append(item);
                }
            );
 
            container.append(list);
 
            if (!ordered.length) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-empty'
                        )
                        .text(
                            'No variables found in the preload.'
                        )
                );
            }
        },
 
        renderVariablesBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variables'
                );
 
            if (
                container &&
                container.length
            ) {
                this.renderVariables(container);
            }
        },
 
        selectVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();
 
            if (!name) {
                return;
            }
 
            const available =
                ['PAGE'].concat(
                    (
                        this.analysis &&
                        this.analysis.variables
                            ? this.analysis.variables
                            : []
                    ).filter(
                        variable =>
                            variable !== 'PAGE'
                    )
                );
 
            const configuredUnused =
                this.state.config &&
                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;
            }
 
            this.state.selectedVariable =
                name;
 
            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
        },
 
        renderVariableConfigurationBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variable-configuration'
                );
 
            if (
                container &&
                container.length
            ) {
                this.renderVariableConfiguration(
                    container
                );
            }
        },
 
        renderVariableConfiguration(container) {
            container.empty();
 
            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );
 
            const name =
                this.state.selectedVariable ||
                'PAGE';
 
            const fields =
                this.state.config &&
                this.state.config.fields
                    ? this.state.config.fields
                    : {};
 
            const field =
                fields[name];
 
            if (name === 'PAGE') {
                container.append(
                    this.createAutomaticPageCard(
                        field ||
                        this.getAutomaticPageField()
                    )
                );
 
                return;
            }
 
            if (!field) {
                const missing =
                    $('<div>')
                        .addClass(
                            'preload-editor-variable preload-editor-variable-missing'
                        );
 
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    )
                    .append(
                        $('<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 =
                $('<div>')
                    .addClass(
                        'preload-editor-variable preload-editor-variable-automatic'
                    )
                    .attr(
                        'data-variable',
                        'PAGE'
                    );
 
            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );
 
            header.append(
                $('<h3>').text('PAGE'),
 
                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('AUTOMATIC')
            );
 
            card.append(header);
 
            $('<p>')
                .addClass(
                    'preload-editor-analysis-note'
                )
                .text(
                    'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
                )
                .appendTo(card);
 
            const label =
                this.textInput(
                    field.label ||
                    'Page Name'
                ).prop(
                    'disabled',
                    true
                );
 
            const description =
                this.textarea(
                    field.description ||
                    'Name of the page.'
                )
                    .addClass(
                        'preload-editor-small-textarea'
                    )
                    .prop(
                        'disabled',
                        true
                    );
 
            const type =
                this.textInput(
                    field.type || 'page'
                ).prop(
                    'disabled',
                    true
                );
 
            const required =
                this.checkbox(
                    field.required !== false
                ).prop(
                    'disabled',
                    true
                );
 
            const namespace =
                this.textInput(
                    field.namespace || 'Main'
                ).prop(
                    'disabled',
                    true
                );
 
            const allowNew =
                this.checkbox(
                    field['allow-new'] === true
                ).prop(
                    'disabled',
                    true
                );
 
            card.append(
                this.fieldRow(
                    'Label',
                    label
                ),
 
                this.fieldRow(
                    'Description',
                    description
                ),
 
                this.fieldRow(
                    'Type',
                    type
                ),
 
                this.fieldRow(
                    'Required',
                    required
                ),
 
                this.fieldRow(
                    'Namespace',
                    namespace
                ),
 
                this.fieldRow(
                    'Allow new page',
                    allowNew
                )
            );
 
            return card;
        },
 
        createVariableCard(
            name,
            field,
            analysis
        ) {
            const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );
 
            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );
 
            header.append(
                $('<h3>').text(name)
            );
 
            if (
                !analysis.variables.includes(name)
            ) {
                header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text('UNUSED')
                );
            }
 
            card.append(header);
 
            const label =
                this.textInput(
                    field.label || ''
                );
 
            label.on(
                'input.preloadEditor',
                () => {
                    field.label =
                        label.val();
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Label',
                    label
                )
            );
 
            const description =
                this.textarea(
                    field.description || ''
                ).addClass(
                    'preload-editor-small-textarea'
                );
 
            description.on(
                'input.preloadEditor',
                () => {
                    field.description =
                        description.val();
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Description',
                    description
                )
            );
 
            const types = [
                ['text', 'Text'],
                ['textarea', 'Textarea'],
                ['number', 'Number'],
                ['boolean', 'Boolean'],
                ['select', 'Select'],
                ['multiselect', 'Multi-select'],
                ['page', 'Page'],
                ['user', 'User'],
                ['date', 'Date'],
                ['file', 'File']
            ];
 
            const typeSelect =
                this.select(
                    types.map(
                        ([value, label]) => ({
                            value,
                            label
                        })
                    ),
                    field.type || 'text'
                );
 
            typeSelect.on(
                'change.preloadEditor',
                () => {
                    field.type =
                        typeSelect.val();
 
                    this.markDirty();
 
                    this.renderVariableConfigurationBySelector();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Type',
                    typeSelect
                )
            );
 
            const required =
                this.checkbox(
                    field.required === true
                );
 
            required.on(
                'change.preloadEditor',
                () => {
                    field.required =
                        required.prop('checked');
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Required',
                    required
                )
            );
 
            const placeholder =
                this.textInput(
                    field.placeholder || ''
                );
 
            placeholder.on(
                'input.preloadEditor',
                () => {
                    const value =
                        placeholder.val();
 
                    if (value) {
                        field.placeholder =
                            value;
                    } else {
                        delete field.placeholder;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Placeholder',
                    placeholder
                )
            );
 
            const defaultInput =
                this.textInput(
                    field.default === undefined
                        ? ''
                        : field.default
                );
 
            defaultInput.on(
                'input.preloadEditor',
                () => {
                    const value =
                        defaultInput.val();
 
                    if (value) {
                        field.default =
                            value;
                    } else {
                        delete field.default;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Default',
                    defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            );
 
            if (
                field.type === 'textarea'
            ) {
                const maxRows =
                    this.textInput(
                        field.maxRows || ''
                    ).attr(
                        'type',
                        'number'
                    );
 
                maxRows.on(
                    'input.preloadEditor',
                    () => {
                        const value =
                            parseInt(
                                maxRows.val(),
                                10
                            );
 
                        if (
                            Number.isFinite(value) &&
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }
 
                        this.markDirty();
                        this.renderConfigReviewAvailability();
                    }
                );
 
                card.append(
                    this.fieldRow(
                        'Maximum rows',
                        maxRows
                    )
                );
            }
 
            if (
                field.type === 'select' ||
                field.type === 'multiselect'
            ) {
                this.renderSelectConfiguration(
                    card,
                    field
                );
            }
 
            if (
                field.type === 'file'
            ) {
                this.renderFileConfiguration(
                    card,
                    field
                );
            }
 
            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;
                        }
 
                        this.deleteVariable(name);
                    }
                );
 
            card.append(
                $('<div>')
                    .addClass(
                        'preload-editor-variable-delete'
                    )
                    .append(deleteButton)
            );
 
            return card;
        },
 
        async deleteVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();
 
            if (
                !name ||
                name === 'PAGE'
            ) {
                return;
            }
 
            if (
                !this.state.config ||
                !this.state.config.fields
            ) {
                return;
            }
 
            delete this.state.config.fields[name];
 
            if (
                !Array.isArray(
                    this.state.deletedVariables
                )
            ) {
                this.state.deletedVariables = [];
            }
 
            if (
                !this.state.deletedVariables.includes(
                    name
                )
            ) {
                this.state.deletedVariables.push(
                    name
                );
            }
 
            this.state.selectedVariable =
                name;
 
            this.markDirty();
 
            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
            this.renderAnalysisBySelector();
            this.renderConfigReviewAvailability();
 
            this.setStatus(
                'Variable "' +
                name +
                '" removed from the configuration.',
                'warning'
            );
        },
 
        // =========================================================
        // Select configuration
        // =========================================================
 
        renderSelectConfiguration(
            card,
            field
        ) {
            const source =
                this.textInput(
                    field.source || '',
                    'category:Example'
                );
 
            source.on(
                'input.preloadEditor',
                () => {
                    const value =
                        source.val();
 
                    if (value) {
                        field.source = value;
                    } else {
                        delete field.source;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Source',
                    source,
                    'Currently supported source format: category:Category Name'
                )
            );
 
            const sourcePrefix =
                this.textInput(
                    field['source-prefix'] || ''
                );
 
            sourcePrefix.on(
                'input.preloadEditor',
                () => {
                    const value =
                        sourcePrefix.val();
 
                    if (value) {
                        field['source-prefix'] =
                            value;
                    } else {
                        delete field['source-prefix'];
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            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) {
                        field.exclude =
                            Object.assign(
                                {},
                                field.exclude || {},
                                {
                                    category: value
                                }
                            );
                    } else if (
                        field.exclude
                    ) {
                        delete field.exclude.category;
 
                        if (
                            !Object.keys(
                                field.exclude
                            ).length
                        ) {
                            delete field.exclude;
                        }
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Exclude category',
                    excludeCategory
                )
            );
 
            card.append(
                this.createTransformControls(
                    field,
                    'value',
                    'Value transformation'
                ),
 
                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );
 
            const optionsValue =
                Array.isArray(field.options)
                    ? field.options
                        .map(
                            option => {
                                if (
                                    typeof option ===
                                    'string'
                                ) {
                                    return option;
                                }
 
                                return (
                                    String(
                                        option.value || ''
                                    ) +
                                    ' | ' +
                                    String(
                                        option.label ||
                                        option.value ||
                                        ''
                                    )
                                );
                            }
                        )
                        .join('\n')
                    : '';
 
            const options =
                this.textarea(
                    optionsValue
                ).addClass(
                    'preload-editor-small-textarea'
                );
 
            options.on(
                'input.preloadEditor',
                () => {
                    const lines =
                        options
                            .val()
                            .split('\n')
                            .map(
                                line =>
                                    line.trim()
                            )
                            .filter(Boolean);
 
                    if (!lines.length) {
                        delete field.options;
                    } else {
                        field.options =
                            lines.map(
                                line => {
                                    const parts =
                                        line.split(
                                            /\s*\|\s*/
                                        );
 
                                    if (
                                        parts.length ===
                                        1
                                    ) {
                                        return parts[0];
                                    }
 
                                    return {
                                        value:
                                            parts[0],
 
                                        label:
                                            parts
                                                .slice(1)
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Manual options',
                    options,
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                )
            );
        },
       
                renderFileConfiguration(
            card,
            field
        ) {
            const acceptValue =
                Array.isArray(field.accept)
                    ? field.accept.join('\n')
                    : '';
 
            const accept =
                this.textarea(
                    acceptValue
                ).addClass(
                    'preload-editor-small-textarea'
                );
 
            accept.on(
                'input.preloadEditor',
                () => {
                    const values =
                        accept
                            .val()
                            .split('\n')
                            .map(
                                value =>
                                    value.trim()
                            )
                            .filter(Boolean);
 
                    if (values.length) {
                        field.accept = values;
                    } else {
                        delete field.accept;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Accepted MIME types',
                    accept,
                    'One MIME type per line. Example: image/png'
                )
            );
 
            const filename =
                this.textInput(
                    field.filename ||
                    '%%PAGE%%.%%EXT%%'
                );
 
            filename.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filename.val();
 
                    if (value) {
                        field.filename = value;
                    } else {
                        delete field.filename;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Filename',
                    filename,
                    'Filename template. %%PAGE%% is replaced with the page name and %%EXT%% with the uploaded file extension.'
                )
            );
 
            const filetext =
                this.textarea(
                    field.filetext || ''
                );
 
            filetext.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filetext.val();
 
                    if (value) {
                        field.filetext = value;
                    } else {
                        delete field.filetext;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'File page text',
                    filetext,
                    'Wikitext used for the uploaded File: page.'
                )
            );
        },
 
        createTransformControls(
            field,
            property,
            title
        ) {
            const wrapper =
                $('<div>')
                    .addClass(
                        'preload-editor-transform'
                    );
 
            $('<h4>')
                .text(title)
                .appendTo(wrapper);
 
            const existing =
                field[property] &&
                typeof field[property] === 'object'
                    ? field[property]
                    : {};
 
            const transform =
                this.select(
                    [
                        {
                            value: '',
                            label:
                                'No transformation'
                        },
                        {
                            value:
                                'remove-prefix',
                            label:
                                'Remove prefix'
                        },
                        {
                            value:
                                'remove-suffix',
                            label:
                                'Remove suffix'
                        },
                        {
                            value: 'replace',
                            label:
                                'Replace'
                        }
                    ],
                    existing.transform || ''
                );
 
            const prefix =
                this.textInput(
                    existing.prefix || ''
                );
 
            const suffix =
                this.textInput(
                    existing.suffix || ''
                );
 
            const from =
                this.textInput(
                    existing.from || ''
                );
 
            const to =
                this.textInput(
                    existing.to || ''
                );
 
            const controls =
                $('<div>')
                    .addClass(
                        'preload-editor-transform-controls'
                    );
 
            const prefixRow =
                this.fieldRow(
                    'Prefix',
                    prefix
                );
 
            const suffixRow =
                this.fieldRow(
                    'Suffix',
                    suffix
                );
 
            const fromRow =
                this.fieldRow(
                    'From',
                    from
                );
 
            const toRow =
                this.fieldRow(
                    'To',
                    to
                );
 
            const renderInputs =
                () => {
                    const type =
                        transform.val();
 
                    prefixRow.toggle(
                        type === 'remove-prefix'
                    );
 
                    suffixRow.toggle(
                        type === 'remove-suffix'
                    );
 
                    fromRow.toggle(
                        type === 'replace'
                    );
 
                    toRow.toggle(
                        type === 'replace'
                    );
                };
 
            const update =
                () => {
                    const type =
                        transform.val();
 
                    let object = null;
 
                    if (
                        type === 'remove-prefix' &&
                        prefix.val()
                    ) {
                        object = {
                            transform: type,
                            prefix:
                                prefix.val()
                        };
                    } else if (
                        type === 'remove-suffix' &&
                        suffix.val()
                    ) {
                        object = {
                            transform: type,
                            suffix:
                                suffix.val()
                        };
                    } else if (
                        type === 'replace' &&
                        from.val()
                    ) {
                        object = {
                            transform: type,
                            from:
                                from.val(),
                            to:
                                to.val()
                        };
                    }
 
                    if (object) {
                        field[property] = object;
                    } else {
                        delete field[property];
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                };
 
            transform.on(
                'change.preloadEditor',
                () => {
                    update();
                    renderInputs();
                }
            );
 
            [
                prefix,
                suffix,
                from,
                to
            ].forEach(
                input => {
                    input.on(
                        'input.preloadEditor',
                        update
                    );
                }
            );
 
            controls.append(
                this.fieldRow(
                    'Transformation',
                    transform
                ),
 
                prefixRow,
                suffixRow,
                fromRow,
                toRow
            );
 
            renderInputs();
 
            wrapper.append(controls);
 
            return wrapper;
        },
 
        // =========================================================
        // Config output
        // =========================================================
 
        getCleanConfig() {
            const config =
                JSON.parse(
                    JSON.stringify(
                        this.state.config
                    )
                );
 
            const fields =
                config.fields || {};
 
            const orderedFields = {};
 
            orderedFields.PAGE =
                this.getAutomaticPageField();
 
            Object.keys(fields)
                .filter(
                    name =>
                        name.toUpperCase() !== 'PAGE'
                )
                .forEach(
                    name => {
                        orderedFields[name] =
                            fields[name];
                    }
                );
 
            config.fields =
                orderedFields;
 
            return config;
        },
 
        getConfigJSON() {
            return JSON.stringify(
                this.getCleanConfig(),
                null,
                4
            );
        },
 
        renderConfigPreview(container) {
            if (container) {
                container.text(
                    this.getConfigJSON()
                );
            }
        },
 
        renderConfigPreviewBySelector() {
            this.renderAnalysisBySelector();
        },
 
        renderConfigReviewAvailability() {
            this.renderAnalysisBySelector();
        },
 
        // =========================================================
        // Dirty state
        // =========================================================
 
        updateDirtyState() {
            this.state.dirty =
                !!(
                    this.state.dirtyPreload ||
                    this.state.dirtyConfig
                );
        },
 
        markPreloadDirty() {
            this.state.dirtyPreload = true;
            this.updateDirtyState();
            this.saveDraft();
        },
 
        markConfigDirty() {
            this.state.dirtyConfig = true;
            this.updateDirtyState();
            this.saveDraft();
        },
 
        markDirty() {
            this.state.dirtyPreload = true;
            this.state.dirtyConfig = true;
            this.updateDirtyState();
            this.saveDraft();
        },
 
        // =========================================================
        // Save
        // =========================================================
 
        async savePreloadOnly() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();
 
            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );
 
                if (!result) {
                    return;
                }
 
                title = result;
                this.state.preloadTitle =
                    title;
            }
 
            this.state.preloadContent =
                this.getEditorContent();
 
            const validation =
                this.validateBeforeSave();
 
            if (validation.errors.length) {
                this.showValidationErrors(
                    validation
                );
 
                this.saveDraft();
                return;
            }
 
            const confirmed =
                await this.confirmSave(
                    'preload',
                    title
                );
 
            if (!confirmed) {
                return;
            }
 
            this.setStatus(
                'Saving preload...',
                'loading'
            );
 
            try {
                const exists =
                    await this.pageExists(title);
 
                await this.savePage(
                    title,
                    this.state.preloadContent,
                    exists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !exists
                );
 
                this.state.mode = 'edit';
                this.state.preloadTitle = title;
                this.state.dirtyPreload = false;
 
                this.updateDirtyState();
 
                if (!this.state.dirty) {
                    this.clearDraft(title);
                } else {
                    this.saveDraft();
                }
 
                this.setStatus(
                    this.state.dirtyConfig
                        ? 'Preload saved. Config still has unsaved changes.'
                        : 'Preload saved successfully.',
                    'success'
                );
 
                mw.notify(
                    this.state.dirtyConfig
                        ? 'Preload saved. Config still has unsaved changes.'
                        : 'Preload saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor save:',
                    error
                );
 
                this.saveDraft();
 
                this.setStatus(
                    error.message ||
                        'Could not save preload.',
                    'error'
                );
 
                mw.notify(
                    error.message ||
                        'Could not save preload.',
                    {
                        type: 'error'
                    }
                );
            }
        },
 
        async saveConfigOnly() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();
 
            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );
 
                if (!result) {
                    return;
                }
 
                title = result;
                this.state.preloadTitle =
                    title;
            }
 
            this.state.preloadContent =
                this.getEditorContent();
 
            this.reanalyze();
 
            const validation =
                this.validateBeforeSave();
 
            if (validation.errors.length) {
                this.showValidationErrors(
                    validation
                );
 
                this.saveDraft();
                return;
            }
 
            const confirmed =
                await this.confirmSave(
                    'config',
                    title
                );
 
            if (!confirmed) {
                return;
            }
 
            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';
 
            this.setStatus(
                'Saving config...',
                'loading'
            );
 
            try {
                /*
                * This now automatically creates or converts
                * config.json to the JSON content model.
                */
                await this.saveConfigPage(
                    configTitle,
                    this.getConfigJSON(),
                    (
                        await this.pageExists(
                            configTitle
                        )
                    )
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor'
                );
 
                this.state.mode = 'edit';
                this.state.preloadTitle = title;
                this.state.dirtyConfig = false;
 
                this.updateDirtyState();
 
                if (!this.state.dirty) {
                    this.clearDraft(title);
                } else {
                    this.saveDraft();
                }
 
                this.setStatus(
                    this.state.dirtyPreload
                        ? 'Config saved as JSON. Preload still has unsaved changes.'
                        : 'Config saved as JSON successfully.',
                    'success'
                );
 
                mw.notify(
                    this.state.dirtyPreload
                        ? 'Config saved as JSON. Preload still has unsaved changes.'
                        : 'Config saved as JSON.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor config save:',
                    error
                );
 
                this.saveDraft();
 
                this.setStatus(
                    error.message ||
                        'Could not save config.',
                    'error'
                );
 
                mw.notify(
                    error.message ||
                        'Could not save config.',
                    {
                        type: 'error'
                    }
                );
            }
        },
 
        async saveAll() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();
 
            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );
 
                if (!result) {
                    return;
                }
 
                title = result;
                this.state.preloadTitle =
                    title;
            }
 
            this.state.preloadContent =
                this.getEditorContent();
 
            this.reanalyze();
 
            const validation =
                this.validateBeforeSave();
 
            if (validation.errors.length) {
                this.showValidationErrors(
                    validation
                );
 
                this.saveDraft();
                return;
            }
 
            const confirmed =
                await this.confirmSave(
                    'both',
                    title
                );
 
            if (!confirmed) {
                return;
            }
 
            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';
 
            this.setStatus(
                'Saving preload and config...',
                'loading'
            );
 
            try {
                const preloadExists =
                    await this.pageExists(title);
 
                await this.savePage(
                    title,
                    this.state.preloadContent,
                    preloadExists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !preloadExists
                );
 
                /*
                * Config is saved through the JSON-aware
                * helper. This means:
                *
                * - new config -> created as JSON
                * - old wikitext config -> converted to JSON
                * - existing JSON config -> simply updated
                */
                const configExists =
                    await this.pageExists(
                        configTitle
                    );
 
                await this.saveConfigPage(
                    configTitle,
                    this.getConfigJSON(),
                    configExists
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor'
                );
 
                this.state.mode = 'edit';
                this.state.dirtyPreload = false;
                this.state.dirtyConfig = false;
                this.state.dirty = false;
                this.state.preloadTitle = title;
 
                this.clearDraft(title);
 
                this.setStatus(
                    'Preload and JSON config saved successfully.',
                    'success'
                );
 
                mw.notify(
                    'Preload and config saved. config.json is using the JSON content model.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor save:',
                    error
                );
 
                this.saveDraft();
 
                this.setStatus(
                    error.message ||
                        'Could not save preload/config.',
                    'error'
                );
 
                mw.notify(
                    error.message ||
                        'Could not save preload/config.',
                    {
                        type: 'error'
                    }
                );
            }
        },
 
        // =========================================================
        // Validation
        // =========================================================
 
        validateBeforeSave() {
            const errors = [];
            const warnings = [];
 
            const title =
                String(
                    this.state.preloadTitle || ''
                ).trim();
 
            if (!title) {
                errors.push(
                    'A preload title is required.'
                );
            }
 
            if (
                title &&
                !/\/Preload$/i.test(title)
            ) {
                warnings.push(
                    'The title does not end with "/Preload".'
                );
            }
 
            const content =
                this.getEditorContent();
 
            this.state.preloadContent =
                content;
 
            if (!content.trim()) {
                errors.push(
                    'The preload cannot be empty.'
                );
            }
 
            if (
                !/<includeonly>/i.test(content)
            ) {
                warnings.push(
                    'The preload does not contain an <includeonly> section.'
                );
            }
 
            if (
                !/<noinclude>/i.test(content)
            ) {
                warnings.push(
                    'The preload does not contain a <noinclude> section.'
                );
            }
 
            const analysis =
                this.analyzePreload(
                    this.extractIncludeonly(
                        content
                    )
                );
 
            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );
 
            const configValidation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );
 
            errors.push(
                ...configValidation.errors
            );
 
            warnings.push(
                ...configValidation.warnings
            );
 
            let depth = 0;
                        analysis.conditions.forEach(
                condition => {
                    if (
                        condition.type === 'if'
                    ) {
                        depth++;
                    } else if (
                        condition.type === 'endif'
                    ) {
                         depth--;
 
                        if (depth < 0) {
                            errors.push(
                                'Found @endif without a matching @if.'
                            );
 
                            depth = 0;
                        }
                    } else if (
                        condition.type === 'elseif' ||
                        condition.type === 'else'
                    ) {
                        if (depth === 0) {
                            errors.push(
                                '@' +
                                condition.type +
                                ' exists outside an @if block.'
                            );
                        }
                    }
                }
            );
 
            if (depth > 0) {
                errors.push(
                    'One or more @if blocks are missing @endif.'
                );
            }
 
            return {
                errors,
                warnings,
                valid:
                    errors.length === 0
            };
        },
 
        showValidationErrors(validation) {
            this.renderAnalysisBySelector();


                        if (depth < 0) {
             this.saveDraft();
                            errors.push(
                                'Found @endif without a matching @if.'
                            );
 
                            depth = 0;
                        }
                    } else if (
                        condition.type ===
                            'elseif' ||
                        condition.type ===
                            'else'
                    ) {
                        if (depth === 0) {
                            errors.push(
                                '@' +
                                condition.type +
                                ' exists outside an @if block.'
                            );
                        }
                    }
                }
            );
 
            if (depth > 0) {
                errors.push(
                    'One or more @if blocks are missing @endif.'
                );
            }
 
            return {
                errors,
                warnings,
                valid:
                    errors.length === 0
            };
        },
 
        showValidationErrors(
            validation
        ) {
             this.renderAnalysisBySelector();


             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: 'error'
                     type: 'error'
Line 4,196: Line 6,212:


     $(function () {
     $(function () {
        const title =
            mw.config.get(
                'wgPageName'
            );
         if (
         if (
             title !==
             mw.config.get('wgPageName') !==
             'Petit_Planet:Preload_Editor'
             'Petit_Planet_Wiki:Preload_Editor'
         ) {
         ) {
             return;
             return;

Latest revision as of 22:07, 14 October 2026

(function (mw, $) {
    'use strict';

    const PreloadEditor = {
        version: '1.2.0',

        state: {
            preloadTitle: '',
            preloadSource: '',
            preloadContent: '',
            config: null,
            variables: [],
            conditions: [],
            selectedVariable: 'PAGE',
            deletedVariables: [],
            mode: 'idle',
            dirty: false,
            dirtyPreload: false,
            dirtyConfig: false,

            // Local draft state
            localDraftSavedAt: null,
            localDraftAvailable: false
        },

        editor: null,
        editorView: null,
        analysis: null,
        root: null,
        workspace: null,
        status: null,
        localDraftStatus: null,
        editorTools: null,

        draftStorageKey: 'PetitPlanet.PreloadEditor.drafts.v1',

        defaults: {
            preload: '<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
        },

        // =========================================================
        // Browser-local draft storage
        // =========================================================

        getDraftStorage() {
            try {
                const raw = window.localStorage.getItem(this.draftStorageKey);

                if (!raw) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }

                const parsed = JSON.parse(raw);

                if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }

                if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
                    parsed.drafts = {};
                }

                return parsed;
            } catch (error) {
                console.warn('Preload Editor: Could not read local drafts.', error);

                return {
                    active: null,
                    drafts: {}
                };
            }
        },

        setDraftStorage(storage) {
            try {
                window.localStorage.setItem(this.draftStorageKey, JSON.stringify(storage));
                return true;
            } catch (error) {
                console.warn('Preload Editor: Could not save local draft.', error);
                return false;
            }
        },

        getDraftId(title) {
            title = String(title || '').trim();
            return title ? title.toLowerCase() : '__untitled__';
        },

        getDraftSnapshot() {
            let content = this.getEditorContent();

            if (!this.editorView && !content) {
                content = this.state.preloadContent || '';
            }

            let config = null;

            try {
                config = this.state.config
                    ? JSON.parse(JSON.stringify(this.state.config))
                    : this.emptyConfig();
            } catch (error) {
                console.warn('Preload Editor: Could not clone config for local draft.', error);
                config = this.emptyConfig();
            }

            return {
                version: 3,
                preloadTitle: String(this.state.preloadTitle || ''),
                preloadSource: String(this.state.preloadSource || ''),
                preloadContent: String(content || ''),
                config,

                variables: Array.isArray(this.state.variables)
                    ? this.state.variables.slice()
                    : [],

                conditions: Array.isArray(this.state.conditions)
                    ? this.state.conditions.slice()
                    : [],

                selectedVariable: String(this.state.selectedVariable || 'PAGE'),

                deletedVariables: Array.isArray(this.state.deletedVariables)
                    ? this.state.deletedVariables.slice()
                    : [],

                mode: this.state.mode || 'edit',
                dirty: !!this.state.dirty,
                dirtyPreload: !!this.state.dirtyPreload,
                dirtyConfig: !!this.state.dirtyConfig,
                savedAt: Date.now()
            };
        },

        updateLocalDraftStatus(message, type) {
            if (!this.localDraftStatus) {
                return;
            }

            this.localDraftStatus.removeClass(
                'is-saved',
                'is-warning',
                'is-error',
                'is-empty'
            );

            if (type) {
                this.localDraftStatus.addClass('is-' + type);
            }

            this.localDraftStatus.text(message || '');
        },

        formatLocalDraftTime(timestamp) {
            if (!timestamp) {
                return '';
            }

            try {
                return new Date(timestamp).toLocaleTimeString([], {
                    hour: '2-digit',
                    minute: '2-digit',
                    second: '2-digit'
                });
            } catch (error) {
                return '';
            }
        },

        updateLocalDraftIndicator() {
            if (!this.localDraftStatus) {
                return;
            }

            if (this.state.localDraftSavedAt) {
                const time = this.formatLocalDraftTime(this.state.localDraftSavedAt);

                this.updateLocalDraftStatus(
                    time ? 'Saved locally at ' + time : 'Saved locally',
                    'saved'
                );

                return;
            }

            this.updateLocalDraftStatus('No local draft', 'empty');
        },

        saveDraft() {
            if (!this.state.dirty) {
                return false;
            }

            const snapshot = this.getDraftSnapshot();
            const id = this.getDraftId(snapshot.preloadTitle);
            const storage = this.getDraftStorage();

            storage.drafts[id] = snapshot;
            storage.active = id;

            const saved = this.setDraftStorage(storage);

            if (saved) {
                this.state.localDraftSavedAt = snapshot.savedAt;
                this.state.localDraftAvailable = true;
                this.updateLocalDraftIndicator();
            } else {
                this.state.localDraftAvailable = false;
                this.updateLocalDraftStatus('Could not save local draft', 'error');
            }

            return saved;
        },

        clearDraft(title) {
            const storage = this.getDraftStorage();
            const id = this.getDraftId(title);

            if (storage.drafts && storage.drafts[id]) {
                delete storage.drafts[id];
            }

            if (storage.active === id) {
                storage.active = null;
            }

            this.setDraftStorage(storage);

            this.state.localDraftSavedAt = null;
            this.state.localDraftAvailable = false;

            this.updateLocalDraftIndicator();
        },

        getMostRecentDraft() {
            const storage = this.getDraftStorage();

            if (!storage.drafts || !Object.keys(storage.drafts).length) {
                return null;
            }

            if (storage.active && storage.drafts[storage.active]) {
                return {
                    id: storage.active,
                    draft: storage.drafts[storage.active]
                };
            }

            let latestId = null;
            let latestDraft = null;

            Object.entries(storage.drafts).forEach(([id, draft]) => {
                if (!draft || typeof draft !== 'object') {
                    return;
                }

                if (
                    !latestDraft ||
                    (Number(draft.savedAt) || 0) > (Number(latestDraft.savedAt) || 0)
                ) {
                    latestId = id;
                    latestDraft = draft;
                }
            });

            if (!latestDraft) {
                return null;
            }

            return {
                id: latestId,
                draft: latestDraft
            };
        },

        async restoreDraft() {
            const result = this.getMostRecentDraft();

            if (!result || !result.draft) {
                return false;
            }

            const draft = result.draft;
            const title = String(draft.preloadTitle || '').trim();
            const displayTitle = title || 'an untitled preload';

            const savedAt = draft.savedAt
                ? new Date(draft.savedAt).toLocaleString()
                : '';

            const message =
                'Preload Editor found unsaved local work for ' +
                displayTitle +
                (savedAt ? '\n\nLast saved locally: ' + savedAt : '') +
                '\n\nRestore it?';

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

            if (!restore) {
                this.clearDraft(title);
                return false;
            }

            let config = this.emptyConfig();

            if (
                draft.config &&
                typeof draft.config === 'object' &&
                !Array.isArray(draft.config)
            ) {
                try {
                    config = this.normalizeConfig(
                        JSON.parse(JSON.stringify(draft.config))
                    );
                } catch (error) {
                    console.warn(
                        'Preload Editor: Could not restore local config.',
                        error
                    );
                }
            }

            const hasDirtyFlags =
                typeof draft.dirtyPreload === 'boolean' ||
                typeof draft.dirtyConfig === 'boolean';

            this.state = {
                preloadTitle: title,
                preloadSource: String(draft.preloadSource || ''),
                preloadContent: String(draft.preloadContent || ''),
                config,

                variables: Array.isArray(draft.variables)
                    ? draft.variables.slice()
                    : [],

                conditions: Array.isArray(draft.conditions)
                    ? draft.conditions.slice()
                    : [],

                selectedVariable: String(draft.selectedVariable || 'PAGE'),

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

                mode: draft.mode || 'edit',

                dirty: hasDirtyFlags
                    ? !!(draft.dirtyPreload || draft.dirtyConfig)
                    : true,

                dirtyPreload: hasDirtyFlags
                    ? !!draft.dirtyPreload
                    : true,

                dirtyConfig: hasDirtyFlags
                    ? !!draft.dirtyConfig
                    : true,

                localDraftSavedAt: Number(draft.savedAt) || null,
                localDraftAvailable: true
            };

            this.reanalyze();
            this.renderEditor();
            this.updateLocalDraftIndicator();

            this.setStatus(
                'Restored unsaved local work from this browser.',
                'warning'
            );

            mw.notify('Unsaved Preload Editor work restored.', {
                type: 'success'
            });

            return true;
        },

        // =========================================================
        // API
        // =========================================================

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

            const page =
                response &&
                response.query &&
                response.query.pages &&
                response.query.pages[0];

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

            const content =
                page.revisions &&
                page.revisions[0] &&
                page.revisions[0].slots &&
                page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;

            return {
                title: page.title || title,
                content: typeof content === 'string' ? content : '',
                contentmodel: page.contentmodel || 'wikitext'
            };
        },

        async pageExists(title) {
            return !!await this.getPage(title);
        },

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

            return (
                response &&
                response.query &&
                response.query.search
            ) || [];
        },

        /*
         * Save a page.
         *
         * contentModel is mainly used when creating a page.
         *
         * For an existing page whose content model needs
         * changing, use changeContentModel() first.
         */
        async savePage(title, content, summary, createOnly, contentModel) {
            const params = {
                action: 'edit',
                title,
                text: content,
                summary: summary || 'Updated with Preload Editor',
                formatversion: 2
            };

            if (createOnly) {
                params.createonly = true;
            }

            if (contentModel) {
                params.contentmodel = contentModel;
            }

            return new mw.Api().postWithToken('csrf', params);
        },

        /*
         * Change the content model of an existing page.
         *
         * This is the API equivalent of using
         * Special:ChangeContentModel manually.
         *
         * The page must already exist.
         */
        async changeContentModel(title, model, summary) {
            return new mw.Api().postWithToken('csrf', {
                action: 'changecontentmodel',
                title,
                model,
                reason: summary || 'Changed content model with Preload Editor'
            });
        },

        /*
         * Save config.json and make sure its content model
         * is JSON.
         *
         * New page:
         *   create directly with contentmodel=json.
         *
         * Existing page:
         *   if not already JSON, change its model first,
         *   then save the JSON normally.
         */
        async saveConfigPage(title, content, summary) {
            const page = await this.getPage(title);

            if (!page) {
                return this.savePage(
                    title,
                    content,
                    summary,
                    true,
                    'json'
                );
            }

            if (String(page.contentmodel || '').toLowerCase() !== 'json') {
                await this.changeContentModel(
                    title,
                    'json',
                    'Changed preload config content model to JSON'
                );
            }

            return this.savePage(
                title,
                content,
                summary,
                false
            );
        },

        // =========================================================
        // Config import helpers
        // =========================================================

        /*
         * Accept either:
         *
         *   Template:Example/Preload
         *
         * or:
         *
         *   Template:Example/Preload/config.json
         *
         * and always return the config.json title.
         */
        normalizeConfigTitle(title) {
            title = String(title || '').trim();
            title = title.replace(/\/+$/, '');

            if (/\/config\.json$/i.test(title)) {
                return title;
            }

            return title + '/config.json';
        },

        /*
         * Return a safe clone of a config field.
         *
         * This is deliberately JSON-based because config
         * definitions are JSON-compatible objects.
         */
        cloneConfigField(field) {
            try {
                return JSON.parse(JSON.stringify(field));
            } catch (error) {
                console.warn(
                    'Preload Editor: Could not clone imported field.',
                    error
                );

                return null;
            }
        },

        /*
         * Normalize imported field names to the same uppercase
         * convention used throughout the editor.
         */
        getImportableFields(sourceConfig) {
            const fields = sourceConfig && sourceConfig.fields;

            if (
                !fields ||
                typeof fields !== 'object' ||
                Array.isArray(fields)
            ) {
                return {};
            }

            const result = {};

            Object.entries(fields).forEach(([name, field]) => {
                const upper = String(name || '').trim().toUpperCase();

                if (!upper || upper === 'PAGE') {
                    return;
                }

                if (
                    !field ||
                    typeof field !== 'object' ||
                    Array.isArray(field)
                ) {
                    return;
                }

                const clone = this.cloneConfigField(field);

                if (!clone) {
                    return;
                }

                result[upper] = clone;
            });

            return result;
        },

        /*
         * Show the variable-selection dialog after a source
         * config has been loaded.
         *
         * Resolves:
         *   null -> cancelled
         *   []   -> confirmed with no selections
         *   [...] -> selected field names
         */
        createImportDefinitionsDialog(sourceTitle, fields) {
            return new Promise(resolve => {
                const names = Object.keys(fields).sort((a, b) => a.localeCompare(b));

                const overlay = $('<div>')
                    .addClass(
                        'preload-editor-prompt-overlay preload-editor-import-overlay'
                    );

                const box = $('<div>')
                    .addClass(
                        'preload-editor-prompt preload-editor-import-dialog'
                    );

                $('<h2>')
                    .text('Import variable definitions')
                    .appendTo(box);

                $('<p>')
                    .text(
                        'Choose which variable definitions to import from "' +
                        sourceTitle +
                        '". Existing local definitions can be replaced.'
                    )
                    .appendTo(box);

                if (!names.length) {
                    $('<div>')
                        .addClass(
                            'preload-editor-empty preload-editor-import-empty'
                        )
                        .text(
                            'This config.json does not contain any importable variable definitions.'
                        )
                        .appendTo(box);

                    const emptyButtons = $('<div>')
                        .addClass('preload-editor-prompt-buttons');

                    const close = this.createButton(
                        'Close',
                        'preload-editor-button-primary',
                        () => {
                            overlay.remove();
                            resolve(null);
                        }
                    );

                    emptyButtons.append(close);
                    box.append(emptyButtons);
                    overlay.append(box);

                    $(document.body).append(overlay);

                    overlay.attr('tabindex', '-1');
                    overlay.trigger('focus');

                    return;
                }

                const search = this.textInput('', 'Search variables...')
                    .addClass('preload-editor-import-search');

                const searchWrap = $('<div>')
                    .addClass('preload-editor-import-search-wrap')
                    .append(search);

                box.append(searchWrap);

                const controls = $('<div>')
                    .addClass('preload-editor-import-controls');

                const selectAll = this.createButton(
                    'Select all',
                    '',
                    () => {
                        list
                            .find('input[type="checkbox"]:visible')
                            .prop('checked', true);

                        updateSummary();
                    }
                );

                const deselectAll = this.createButton(
                    'Deselect all',
                    '',
                    () => {
                        list
                            .find('input[type="checkbox"]:visible')
                            .prop('checked', false);

                        updateSummary();
                    }
                );

                controls.append(selectAll, deselectAll);
                box.append(controls);

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

                const localFields =
                    this.state.config && this.state.config.fields
                        ? this.state.config.fields
                        : {};

                names.forEach(name => {
                    const existing = !!localFields[name];
                    const field = fields[name];

                    const type = field && field.type
                        ? String(field.type)
                        : 'text';

                    const label = field && field.label
                        ? String(field.label)
                        : this.humanizeName(name);

                    const row = $('<label>')
                        .addClass('preload-editor-import-row')
                        .attr('data-variable', name)
                        .attr(
                            'data-search',
                            (name + ' ' + label + ' ' + type).toLowerCase()
                        );

                    const checkbox = this.checkbox(false)
                        .attr('data-import-variable', name)
                        .addClass('preload-editor-import-checkbox');

                    const content = $('<span>')
                        .addClass('preload-editor-import-content');

                    const header = $('<span>')
                        .addClass('preload-editor-import-header');

                    $('<strong>')
                        .text(name)
                        .appendTo(header);

                    $('<span>')
                        .addClass('preload-editor-import-type')
                        .text(type)
                        .appendTo(header);

                    const meta = $('<span>')
                        .addClass('preload-editor-import-meta');

                    $('<span>')
                        .text(label)
                        .appendTo(meta);

                    const status = $('<span>')
                        .addClass('preload-editor-import-status')
                        .toggleClass('is-existing', existing)
                        .toggleClass('is-new', !existing)
                        .text(existing ? 'REPLACE EXISTING' : 'NEW');

                    content.append(header, meta, status);
                    row.append(checkbox, content);
                    list.append(row);
                });

                box.append(list);

                const summary = $('<div>')
                    .addClass('preload-editor-import-summary');

                const updateSummary = () => {
                    const selected = list.find(
                        'input[type="checkbox"]:checked'
                    ).length;

                    const existingSelected = list
                        .find('input[type="checkbox"]:checked')
                        .filter(function () {
                            const name = $(this).attr(
                                'data-import-variable'
                            );

                            return !!localFields[name];
                        }).length;

                    let text =
                        selected +
                        ' ' +
                        (selected === 1 ? 'variable' : 'variables') +
                        ' selected.';

                    if (existingSelected) {
                        text +=
                            ' ' +
                            existingSelected +
                            ' existing ' +
                            (existingSelected === 1
                                ? 'definition'
                                : 'definitions') +
                            ' will be replaced.';
                    }

                    summary.text(text);

                    importButton.prop('disabled', selected === 0);
                };

                const filterRows = () => {
                    const query = String(search.val() || '')
                        .trim()
                        .toLowerCase();

                    list.find('.preload-editor-import-row').each(function () {
                        const row = $(this);
                        const haystack = String(
                            row.attr('data-search') || ''
                        );

                        row.toggle(!query || haystack.includes(query));
                    });
                };

                search.on(
                    'input.preloadEditor',
                    filterRows
                );

                list.on(
                    'change.preloadEditor',
                    'input[type="checkbox"]',
                    updateSummary
                );

                const buttons = $('<div>')
                    .addClass('preload-editor-prompt-buttons');

                const cancel = this.createButton(
                    'Cancel',
                    '',
                    () => {
                        overlay.remove();
                        resolve(null);
                    }
                );

                const importButton = this.createButton(
                    'Import selected',
                    'preload-editor-button-primary',
                    () => {
                        const selected = [];

                        list
                            .find('input[type="checkbox"]:checked')
                            .each(function () {
                                selected.push(
                                    String(
                                        $(this).attr(
                                            'data-import-variable'
                                        )
                                    )
                                );
                            });

                        overlay.remove();
                        resolve(selected);
                    }
                );

                buttons.append(cancel, importButton);
                box.append(summary, buttons);
                overlay.append(box);

                $(document.body).append(overlay);

                overlay.attr('tabindex', '-1');
                overlay.trigger('focus');

                updateSummary();
                search.trigger('focus');

                const close = event => {
                    if (event.key === 'Escape') {
                        cancel.trigger('click');
                    }
                };

                overlay.on(
                    'keydown.preloadEditor',
                    close
                );

                overlay.on(
                    'click.preloadEditor',
                    event => {
                        if (event.target === overlay.get(0)) {
                            cancel.trigger('click');
                        }
                    }
                );
            });
        },

        /*
         * Main import workflow.
         */
        async importVariableDefinitions() {
            const source = await this.createPromptDialog(
                'Import variable definitions',
                'Enter a preload title or its config.json title.'
            );

            if (!source) {
                return;
            }

            const configTitle = this.normalizeConfigTitle(source);

            this.setStatus(
                'Loading source config...',
                'loading'
            );

            try {
                const page = await this.getPage(configTitle);

                if (!page) {
                    throw new Error(
                        'No config.json was found at "' +
                        configTitle +
                        '".'
                    );
                }

                let sourceConfig;

                try {
                    sourceConfig = JSON.parse(page.content);
                } catch (error) {
                    throw new Error(
                        'The source config.json contains invalid JSON: ' +
                        error.message
                    );
                }

                if (
                    !sourceConfig ||
                    typeof sourceConfig !== 'object' ||
                    Array.isArray(sourceConfig)
                ) {
                    throw new Error(
                        'The source config.json must contain a JSON object.'
                    );
                }

                if (
                    !sourceConfig.fields ||
                    typeof sourceConfig.fields !== 'object' ||
                    Array.isArray(sourceConfig.fields)
                ) {
                    throw new Error(
                        'The source config.json does not contain a valid "fields" object.'
                    );
                }

                const fields = this.getImportableFields(sourceConfig);

                const selected =
                    await this.createImportDefinitionsDialog(
                        configTitle,
                        fields
                    );

                if (selected === null) {
                    this.setStatus(
                        'Variable import cancelled.',
                        'warning'
                    );

                    return;
                }

                if (!selected.length) {
                    this.setStatus(
                        'No variable definitions were imported.',
                        'warning'
                    );

                    return;
                }

                if (!this.state.config) {
                    this.state.config = this.emptyConfig();
                }

                if (!this.state.config.fields) {
                    this.state.config.fields = {};
                }

                const imported = [];
                const replaced = [];

                selected.forEach(name => {
                    if (name === 'PAGE') {
                        return;
                    }

                    const field = fields[name];

                    if (!field) {
                        return;
                    }

                    if (this.state.config.fields[name]) {
                        replaced.push(name);
                    } else {
                        imported.push(name);
                    }

                    const clone = this.cloneConfigField(field);

                    if (!clone) {
                        return;
                    }

                    this.state.config.fields[name] = clone;

                    /*
                     * If this variable was previously deleted,
                     * importing its definition explicitly should
                     * restore it.
                     */
                    if (Array.isArray(this.state.deletedVariables)) {
                        this.state.deletedVariables =
                            this.state.deletedVariables.filter(
                                deletedName =>
                                    String(deletedName).toUpperCase() !== name
                            );
                    }
                });

                /*
                 * Keep PAGE automatic regardless of what the
                 * imported config contained.
                 */
                this.state.config.fields.PAGE =
                    this.getAutomaticPageField();

                this.markConfigDirty();

                /*
                 * Keep the current selection when possible.
                 * Otherwise select the first imported definition.
                 */
                const selectedStillExists = !!(
                    this.state.config.fields[
                        this.state.selectedVariable
                    ]
                );

                if (
                    !selectedStillExists ||
                    this.state.selectedVariable === 'PAGE'
                ) {
                    this.state.selectedVariable =
                        selected[0] ||
                        'PAGE';
                }

                this.reanalyze();

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

                this.saveDraft();

                let status =
                    'Imported ' +
                    selected.length +
                    ' variable ' +
                    (selected.length === 1
                        ? 'definition'
                        : 'definitions') +
                    '.';

                if (replaced.length) {
                    status +=
                        ' Replaced: ' +
                        replaced.join(', ') +
                        '.';
                }

                this.setStatus(status, 'success');

                mw.notify(status, {
                    type: 'success'
                });
            } catch (error) {
                console.error(
                    'Preload Editor variable import:',
                    error
                );

                this.setStatus(
                    error.message ||
                        'Could not import variable definitions.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not import variable definitions.',
                    {
                        type: 'error'
                    }
                );
            }
        },

        // =========================================================
        // Preload parsing
        // =========================================================

        extractIncludeonly(source) {
            const match = String(source || '').match(
                /<includeonly>([\s\S]*?)<\/includeonly>/i
            );

            return match
                ? match[1].trim()
                : String(source || '').trim();
        },

        findVariables(content) {
            const variables = [];
            const pattern = /%%([A-Za-z0-9_-]+)%%/g;

            let match;

            while ((match = pattern.exec(content)) !== null) {
                const name = match[1].toUpperCase();

                if (!variables.includes(name)) {
                    variables.push(name);
                }
            }

            return variables;
        },

        findConditions(content) {
            const conditions = [];
            const pattern =
                /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;

            let match;

            while ((match = pattern.exec(content)) !== null) {
                conditions.push({
                    type: match[1].toLowerCase(),
                    expression: match[2].trim(),
                    index: match.index
                });
            }

            return conditions;
        },

        findConditionVariables(content) {
            const variables = [];

            this.findConditions(content).forEach(condition => {
                if (
                    condition.type !== 'if' &&
                    condition.type !== 'elseif'
                ) {
                    return;
                }

                const matches =
                    condition.expression.match(
                        /[A-Za-z0-9_-]+/g
                    ) || [];

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

                    if (!variables.includes(name)) {
                        variables.push(name);
                    }
                });
            });

            return variables;
        },

        analyzePreload(content) {
            return {
                variables: this.findVariables(content),
                conditions: this.findConditions(content),
                conditionVariables:
                    this.findConditionVariables(content)
            };
        },

        // =========================================================
        // Config
        // =========================================================

        getAutomaticPageField() {
            return {
                label: 'Page Name',
                description: 'Name of the page.',
                type: 'page',
                required: true,
                namespace: 'Main',
                'allow-new': true
            };
        },

        emptyConfig() {
            return {
                settings: {
                    incomplete: 'add-stub'
                },

                fields: {
                    PAGE: this.getAutomaticPageField()
                }
            };
        },

        normalizeConfig(config) {
            if (
                !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                config = this.emptyConfig();
            }

            if (
                !config.settings ||
                typeof config.settings !== 'object' ||
                Array.isArray(config.settings)
            ) {
                config.settings = {};
            }

            if (
                !config.fields ||
                typeof config.fields !== 'object' ||
                Array.isArray(config.fields)
            ) {
                config.fields = {};
            }

            config.fields.PAGE =
                this.getAutomaticPageField();

            return config;
        },

        createDefaultField(name, analysis) {
            return {
                label: this.humanizeName(name),
                description: '',
                type: analysis.conditionVariables.includes(name)
                    ? 'boolean'
                    : 'text'
            };
        },

        humanizeName(name) {
            return String(name || '')
                .replace(/[_-]+/g, ' ')
                .replace(/([a-z])([A-Z])/g, '$1 $2')
                .replace(/\s+/g, ' ')
                .trim()
                .replace(/\b\w/g, char => char.toUpperCase());
        },

        prepareConfigForVariables(config, analysis) {
            config = this.normalizeConfig(config);

            const existing = config.fields || {};

            const deleted = new Set(
                Array.isArray(this.state.deletedVariables)
                    ? this.state.deletedVariables.map(
                        name => String(name).toUpperCase()
                    )
                    : []
            );

            const fields = {};

            fields.PAGE =
                this.getAutomaticPageField();

            analysis.variables.forEach(name => {
                if (
                    name === 'PAGE' ||
                    deleted.has(name)
                ) {
                    return;
                }

                fields[name] =
                    existing[name] ||
                    this.createDefaultField(
                        name,
                        analysis
                    );
            });

            Object.keys(existing).forEach(name => {
                const upper = name.toUpperCase();

                if (
                    upper !== 'PAGE' &&
                    !fields[upper] &&
                    !deleted.has(upper)
                ) {
                    fields[upper] =
                        existing[name];
                }
            });

            config.fields = fields;

            return config;
        },

        validateConfig(config, analysis) {
            const errors = [];
            const warnings = [];

            if (
                !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
            ) {
                return {
                    valid: false,
                    errors: [
                        'Config must be an object.'
                    ],
                    warnings: []
                };
            }

            if (
                !config.fields ||
                typeof config.fields !== 'object' ||
                Array.isArray(config.fields)
            ) {
                return {
                    valid: false,
                    errors: [
                        '"fields" must be an object.'
                    ],
                    warnings
                };
            }

            const configured =
                Object.keys(
                    config.fields
                ).map(
                    name =>
                        name.toUpperCase()
                );

            analysis.variables.forEach(name => {
                if (!configured.includes(name)) {
                    errors.push(
                        'Variable "' +
                        name +
                        '" is not configured.'
                    );
                }
            });

            configured.forEach(name => {
                if (!analysis.variables.includes(name)) {
                    warnings.push(
                        'Field "' +
                        name +
                        '" is not used by the preload.'
                    );
                }
            });

            if (!configured.includes('PAGE')) {
                errors.push(
                    'PAGE must always be configured.'
                );
            }

            Object.entries(config.fields).forEach(([name, field]) => {
                if (
                    !field ||
                    typeof field !== 'object' ||
                    Array.isArray(field)
                ) {
                    errors.push(
                        'Field "' +
                        name +
                        '" must be an object.'
                    );

                    return;
                }

                if (!field.type) {
                    warnings.push(
                        'Field "' +
                        name +
                        '" has no type.'
                    );
                }
                
                if (
                    field.type === 'file'
                ) {
                    if (
                        field.accept !== undefined &&
                        !Array.isArray(field.accept)
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "accept" as an array.'
                        );
                    }

                    if (
                        Array.isArray(field.accept) &&
                        field.accept.some(
                            value =>
                                !String(
                                    value || ''
                                ).trim()
                        )
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" contains an empty accepted MIME type.'
                        );
                    }

                    if (
                        field.filename !== undefined &&
                        typeof field.filename !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "filename" as a string.'
                        );
                    }

                    if (
                        field.filetext !== undefined &&
                        typeof field.filetext !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "filetext" as a string.'
                        );
                    }

                    if (
                        !field.accept ||
                        !field.accept.length
                    ) {
                        warnings.push(
                            'File field "' +
                            name +
                            '" has no accepted MIME types.'
                        );
                    }

                    if (
                        !field.filename
                    ) {
                        warnings.push(
                            'File field "' +
                            name +
                            '" has no filename template.'
                        );
                    }
                }

                if (
                    (
                        field.type === 'select' ||
                        field.type === 'multiselect'
                    ) &&
                    !field.source &&
                    !Array.isArray(field.options)
                ) {
                    warnings.push(
                        'Field "' +
                        name +
                        '" is a ' +
                        field.type +
                        ' without a source or manual options.'
                    );
                }

                if (field.type === 'file') {
                    if (
                        field.filename !== undefined &&
                        typeof field.filename !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" has an invalid "filename" value.'
                        );
                    }

                    if (
                        field.filetext !== undefined &&
                        typeof field.filetext !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" has an invalid "filetext" value.'
                        );
                    }

                    if (
                        field.accept !== undefined &&
                        (
                            !Array.isArray(field.accept) ||
                            field.accept.some(
                                value =>
                                    typeof value !== 'string' ||
                                    !value.trim()
                            )
                        )
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "accept" as an array of MIME types.'
                        );
                    }
                }
            });

            return {
                valid: !errors.length,
                errors,
                warnings
            };
        },

        // =========================================================
        // Transformation helpers
        // =========================================================

        transformationObject(
            transform,
            prefix,
            suffix,
            from,
            to
        ) {
            if (!transform) {
                return null;
            }

            switch (transform) {
                case 'remove-prefix':
                    return prefix
                        ? {
                            transform,
                            prefix
                        }
                        : null;

                case 'remove-suffix':
                    return suffix
                        ? {
                            transform,
                            suffix
                        }
                        : null;

                case 'replace':
                    return from === ''
                        ? null
                        : {
                            transform,
                            from,
                            to: to || ''
                        };

                default:
                    return null;
            }
        },

        // =========================================================
        // UI helpers
        // =========================================================

        createSection(title, className) {
            const section = $('<section>')
                .addClass('preload-editor-section');

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

            $('<h2>')
                .addClass('preload-editor-section-title')
                .text(title)
                .appendTo(section);

            return section;
        },

        createButton(label, className, callback) {
            const button = $('<button>')
                .attr('type', 'button')
                .addClass('preload-editor-button')
                .text(label);

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

            if (callback) {
                button.on(
                    'click.preloadEditor',
                    callback
                );
            }

            return button;
        },

        fieldRow(label, input, description) {
            const row = $('<div>')
                .addClass('preload-editor-field');

            $('<label>')
                .addClass('preload-editor-label')
                .text(label)
                .appendTo(row);

            if (description) {
                $('<div>')
                    .addClass('preload-editor-help')
                    .text(description)
                    .appendTo(row);
            }

            row.append(input);

            return row;
        },

        textInput(value, placeholder) {
            return $('<input>')
                .attr({
                    type: 'text',
                    value: value == null ? '' : value,
                    placeholder: placeholder || ''
                })
                .addClass('preload-editor-input');
        },

        textarea(value) {
            return $('<textarea>')
                .val(value == null ? '' : value)
                .addClass('preload-editor-textarea');
        },

        checkbox(checked) {
            return $('<input>')
                .attr('type', 'checkbox')
                .prop('checked', !!checked)
                .addClass('preload-editor-checkbox');
        },

        select(options, value) {
            const select = $('<select>')
                .addClass('preload-editor-select');

            options.forEach(option => {
                $('<option>')
                    .attr('value', option.value)
                    .text(option.label)
                    .prop(
                        'selected',
                        String(option.value) ===
                        String(value === undefined ? '' : value)
                    )
                    .appendTo(select);
            });

            return select;
        },

        // =========================================================
        // In-wiki dialogs
        // =========================================================

        createConfirmDialog(
            title,
            message,
            confirmLabel,
            cancelLabel
        ) {
            return new Promise(resolve => {
                const overlay = $('<div>')
                    .addClass('preload-editor-prompt-overlay');

                const box = $('<div>')
                    .addClass('preload-editor-prompt');

                $('<h2>')
                    .text(title)
                    .appendTo(box);

                const messageLines =
                    String(message || '').split('\n');

                messageLines.forEach(line => {
                    if (line === '') {
                        $('<div>')
                            .addClass(
                                'preload-editor-prompt-spacer'
                            )
                            .appendTo(box);

                        return;
                    }

                    $('<p>')
                        .text(line)
                        .appendTo(box);
                });

                const buttons = $('<div>')
                    .addClass(
                        'preload-editor-prompt-buttons'
                    );

                const cancel = this.createButton(
                    cancelLabel || 'Cancel',
                    '',
                    () => {
                        overlay.remove();
                        resolve(false);
                    }
                );

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

                buttons.append(cancel, confirm);
                box.append(buttons);
                overlay.append(box);

                $(document.body).append(overlay);

                const close = value => {
                    if (value === 'Escape') {
                        cancel.trigger('click');
                    }
                };

                overlay.on(
                    'keydown.preloadEditor',
                    event => {
                        close(event.key);
                    }
                );

                overlay.attr('tabindex', '-1');
                overlay.trigger('focus');

                overlay.on(
                    'click.preloadEditor',
                    event => {
                        if (event.target === overlay.get(0)) {
                            cancel.trigger('click');
                        }
                    }
                );
            });
        },

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

                const box = $('<div>')
                    .addClass(
                        'preload-editor-prompt'
                    );

                $('<h2>')
                    .text(title)
                    .appendTo(box);

                $('<p>')
                    .text(message)
                    .appendTo(box);

                const input = this.textInput();

                const buttons = $('<div>')
                    .addClass(
                        'preload-editor-prompt-buttons'
                    );

                const cancel = this.createButton(
                    'Cancel',
                    '',
                    () => {
                        overlay.remove();
                        resolve(null);
                    }
                );

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

                        if (!value) {
                            return;
                        }

                        overlay.remove();
                        resolve(value);
                    }
                );

                buttons.append(cancel, confirm);

                box.append(
                    input,
                    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');
                        }
                    }
                );
            });
        },

        createConfigReviewDialog() {
            return new Promise(resolve => {
                const overlay = $('<div>')
                    .addClass(
                        'preload-editor-prompt-overlay preload-editor-code-review-overlay'
                    );

                const box = $('<div>')
                    .addClass(
                        'preload-editor-prompt preload-editor-code-review'
                    );

                $('<h2>')
                    .text('Review config.json')
                    .appendTo(box);

                $('<p>')
                    .text(
                        'This is the config.json that will be saved for the current preload.'
                    )
                    .appendTo(box);

                const code = $('<pre>')
                    .addClass(
                        'preload-editor-config-review'
                    )
                    .text(this.getConfigJSON());

                const codeWrap = $('<div>')
                    .addClass(
                        'preload-editor-config-review-wrap'
                    )
                    .append(code);

                box.append(codeWrap);

                const buttons = $('<div>')
                    .addClass(
                        'preload-editor-prompt-buttons'
                    );

                const close = this.createButton(
                    'Close',
                    'preload-editor-button-primary',
                    () => {
                        overlay.remove();
                        resolve();
                    }
                );

                buttons.append(close);
                box.append(buttons);
                overlay.append(box);

                $(document.body).append(overlay);

                overlay.attr('tabindex', '-1');
                overlay.trigger('focus');

                overlay.on(
                    'keydown.preloadEditor',
                    event => {
                        if (event.key === 'Escape') {
                            close.trigger('click');
                        }
                    }
                );

                overlay.on(
                    'click.preloadEditor',
                    event => {
                        if (event.target === overlay.get(0)) {
                            close.trigger('click');
                        }
                    }
                );
            });
        },

        async confirmSave(mode, title) {
            const configTitle =
                title.replace(/\/+$/, '') +
                '/config.json';

            let saveDescription;

            if (mode === 'preload') {
                saveDescription =
                    'Save the preload page only?';
            } else if (mode === 'config') {
                saveDescription =
                    'Save the config.json page only?';
            } else {
                saveDescription =
                    'Save both the preload and config.json?';
            }

            const pages = [];

            if (mode === 'preload' || mode === 'both') {
                pages.push(
                    'Preload: ' +
                    title
                );
            }

            if (mode === 'config' || mode === 'both') {
                pages.push(
                    'Config: ' +
                    configTitle
                );

                pages.push(
                    'Config content model: JSON'
                );
            }

            const message =
                saveDescription +
                '\n\n' +
                pages.join('\n') +
                '\n\nThis will write the changes to the wiki.';

            return this.createConfirmDialog(
                'Confirm save',
                message,
                'Save',
                'Cancel'
            );
        },

        // =========================================================
        // CodeMirror
        // =========================================================
                destroyEditor() {
            if (!this.editor) {
                this.editorView = null;
                this.editorTools = null;
                return;
            }

            const {
                view,
                inputHandler
            } = this.editor;

            if (view && inputHandler) {
                view.dom.removeEventListener(
                    'input',
                    inputHandler
                );
            }

            if (view) {
                view.destroy();
            }

            this.editor = null;
            this.editorView = null;
            this.editorTools = null;
        },

        getEditorView() {
            return this.editorView;
        },

        getEditorContent() {
            if (
                this.editorView &&
                this.editorView.state
            ) {
                return this.editorView.state.doc.toString();
            }

            return this.state.preloadContent || '';
        },

        setEditorContent(content) {
            content = String(content || '');

            if (!this.editorView) {
                this.state.preloadContent = content;
                return;
            }

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

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

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

        getEditorSelection() {
            if (
                !this.editorView ||
                !this.editorView.state
            ) {
                return null;
            }

            const selection =
                this.editorView.state.selection.main;

            return {
                from: selection.from,
                to: selection.to,
                text: this.editorView.state.doc.sliceString(
                    selection.from,
                    selection.to
                )
            };
        },

        focusEditor() {
            if (this.editorView) {
                this.editorView.focus();
            }
        },

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

            const view = this.editorView;
            const selection = view.state.selection.main;

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

            const to =
                selectionTo === undefined
                    ? selection.to
                    : selectionTo;

            const cursor =
                cursorPosition === undefined
                    ? from + text.length
                    : cursorPosition;

            view.dispatch({
                changes: {
                    from,
                    to,
                    insert: text
                },
                selection: {
                    anchor: cursor
                }
            });

            view.focus();
        },

        // =========================================================
        // Editor tools
        // =========================================================

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

            if (
                !selected ||
                selected.from === selected.to
            ) {
                this.focusEditor();
                return;
            }

            const replacement =
                '%%' +
                selected.text +
                '%%';

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

        insertVariable(name) {
            name = String(name || '')
                .trim()
                .toUpperCase();

            if (!name) {
                return;
            }

            const selected =
                this.getEditorSelection();

            if (!selected) {
                return;
            }

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

        insertCondition(
            type,
            variable
        ) {
            type = String(type || '').toLowerCase();
            variable = String(variable || '')
                .trim()
                .toUpperCase();

            const selected =
                this.getEditorSelection();

            if (!selected) {
                return;
            }

            let text;
            let cursorOffset;

            switch (type) {
                case 'if': {
                    if (!variable) {
                        return;
                    }

                    const opening =
                        '<!-- @if ' +
                        variable +
                        ' -->';

                    const closing =
                        '<!-- @endif -->';

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

                        cursorOffset = text.length;
                    } else {
                        text =
                            opening +
                            '\n\n' +
                            closing;

                        cursorOffset =
                            opening.length + 1;
                    }

                    break;
                }

                case 'elseif':
                    text =
                        '<!-- @elseif -->';

                    cursorOffset =
                        text.length;

                    break;

                case 'else':
                    text =
                        '<!-- @else -->';

                    cursorOffset =
                        text.length;

                    break;

                case 'endif':
                    text =
                        '<!-- @endif -->';

                    cursorOffset =
                        text.length;

                    break;

                default:
                    return;
            }

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

        getToolVariables() {
            const variables =
                new Set(['PAGE']);

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

            return Array.from(variables);
        },

        createToolSelect(
            options,
            className
        ) {
            const select =
                $('<select>')
                    .addClass(
                        'preload-editor-tool-select'
                    );

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

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

            return select;
        },

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

            const variables =
                this.getToolVariables();

            const current =
                this.editorTools
                    .variableSelect
                    .val();

            const currentCondition =
                this.editorTools
                    .conditionVariableSelect
                    .val();

            const populate = (
                select,
                value
            ) => {
                select.empty();

                variables.forEach(
                    name => {
                        $('<option>')
                            .attr(
                                'value',
                                name
                            )
                            .text(name)
                            .appendTo(select);
                    }
                );

                if (
                    value &&
                    variables.includes(value)
                ) {
                    select.val(value);
                } else if (
                    variables.length
                ) {
                    select.val(
                        variables[0]
                    );
                }
            };

            populate(
                this.editorTools.variableSelect,
                current
            );

            populate(
                this.editorTools.conditionVariableSelect,
                currentCondition
            );
        },

        updateConditionVariableVisibility() {
            if (!this.editorTools) {
                return;
            }

            const type =
                this.editorTools
                    .conditionTypeSelect
                    .val();

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

        renderEditorTools() {
            const tools =
                $('<div>')
                    .addClass(
                        'preload-editor-tools'
                    );

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

            wrapGroup.append(
                this.createButton(
                    'Wrap selection',
                    'preload-editor-tool-button',
                    () =>
                        this.wrapSelectionWithVariable()
                )
            );

            const variableGroup =
                $('<div>')
                    .addClass(
                        'preload-editor-tool-group preload-editor-tool-group-variable'
                    );

            $('<span>')
                .addClass(
                    'preload-editor-tool-label'
                )
                .text(
                    'Insert existing variable'
                )
                .appendTo(variableGroup);

            const variableSelect =
                this.createToolSelect(
                    [],
                    'preload-editor-variable-select'
                );

            variableGroup.append(
                variableSelect,

                this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertVariable(
                            variableSelect.val()
                        )
                )
            );

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

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

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

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

            const conditionVariableWrap =
                $('<span>')
                    .addClass(
                        'preload-editor-condition-variable'
                    )
                    .append(
                        conditionVariableSelect
                    );

            const insertCondition =
                this.createButton(
                    'Insert',
                    'preload-editor-tool-button',
                    () =>
                        this.insertCondition(
                            conditionTypeSelect.val(),
                            conditionVariableSelect.val()
                        )
                );

            conditionTypeSelect.on(
                'change.preloadEditor',
                () =>
                    this.updateConditionVariableVisibility()
            );

            conditionGroup.append(
                conditionTypeSelect,
                conditionVariableWrap,
                insertCondition
            );

            tools.append(
                wrapGroup,
                variableGroup,
                conditionGroup
            );

            this.editorTools = {
                root: tools,
                variableSelect,
                conditionTypeSelect,
                conditionVariableSelect,
                conditionVariableWrap,
                insertCondition
            };

            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();

            return tools;
        },

        updateEditorTools() {
            this.updateEditorToolVariables();
            this.updateConditionVariableVisibility();
        },

        // =========================================================
        // Syntax highlighting
        // =========================================================

        getKnownVariables() {
            const known =
                new Set(['PAGE']);

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

            if (
                fields &&
                typeof fields === 'object'
            ) {
                Object.keys(fields).forEach(
                    name => {
                        known.add(
                            String(name).toUpperCase()
                        );
                    }
                );
            }

            return known;
        },

        createSyntaxDecorations(
            state,
            Decoration
        ) {
            const decorations = [];
            const source =
                state.doc.toString();

            const knownVariables =
                this.getKnownVariables();

            const variablePattern =
                /%%([A-Za-z0-9_-]+)%%/g;

            let match;

            while (
                (
                    match =
                        variablePattern.exec(source)
                ) !== null
            ) {
                const name =
                    match[1].toUpperCase();

                const known =
                    knownVariables.has(name);

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

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

            while (
                (
                    match =
                        conditionPattern.exec(source)
                ) !== 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('@');

                decorations.push(
                    Decoration.mark({
                        class:
                            'preload-editor-syntax-directive'
                    }).range(
                        directiveStart,
                        directiveStart +
                            match[1].length +
                            1
                    )
                );

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

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

                const expression =
                    match[2];

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

                const expressionPattern =
                    /[A-Za-z0-9_-]+/g;

                let expressionMatch;

                while (
                    (
                        expressionMatch =
                            expressionPattern.exec(
                                expression
                            )
                    ) !== null
                ) {
                    const name =
                        expressionMatch[0]
                            .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(
                decorations,
                true
            );
        },

        async installSyntaxHighlighting(view) {
            await mw.loader.using(
                'ext.CodeMirror.v6.lib'
            );

            const require =
                mw.loader.require;

            const {
                StateField,
                StateEffect,
                EditorView,
                Decoration
            } = require(
                'ext.CodeMirror.v6.lib'
            );

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

                    update: (
                        decorations,
                        transaction
                    ) =>
                        transaction.docChanged
                            ? this.createSyntaxDecorations(
                                transaction.state,
                                Decoration
                            )
                            : decorations,

                    provide: field =>
                        EditorView.decorations.from(
                            field
                        )
                });

            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 onReady =
                        async cm => {
                            if (
                                settled ||
                                !cm ||
                                cm.textarea !== textarea ||
                                !cm.view
                            ) {
                                return;
                            }

                            settled = true;
                            cleanup();

                            const view =
                                cm.view;

                            const inputHandler =
                                () => {
                                    this.state
                                        .preloadContent =
                                        view.state.doc.toString();

                                    this.markPreloadDirty();

                                    this.reanalyze();

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

                                    this.saveDraft();
                                };

                            view.dom.addEventListener(
                                'input',
                                inputHandler
                            );

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

                            const wrapper =
                                view.dom.closest(
                                    '.ext-codemirror-wrapper'
                                );

                            if (wrapper) {
                                wrapper.classList.add(
                                    'preload-editor-codemirror-wrapper'
                                );
                            }

                            this.editor = {
                                cm,
                                view,
                                textarea,
                                inputHandler
                            };

                            this.editorView =
                                view;

                            this.state.preloadContent =
                                view.state.doc.toString();

                            try {
                                await this.installSyntaxHighlighting(
                                    view
                                );

                                if (
                                    this.state.dirty
                                ) {
                                    this.saveDraft();
                                }

                                resolve(view);
                            } catch (error) {
                                reject(error);
                            }
                        };

                    const onLoad =
                        loader => {
                            if (
                                settled ||
                                typeof loader !==
                                    'function'
                            ) {
                                return;
                            }

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

                    mw.hook(
                        'dev.CCM.ready'
                    ).add(onReady);

                    mw.hook(
                        'dev.CCM.load'
                    ).add(onLoad);
                }
            );
        },

        // =========================================================
        // Main UI
        // =========================================================

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

            if (!root.length) {
                return;
            }

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

            root.append(this.root);

            this.renderShell();

            this.restoreDraft();

            window.addEventListener(
                'beforeunload',
                () => {
                    if (this.state.dirty) {
                        this.saveDraft();
                    }
                }
            );
        },
                renderShell() {
            this.destroyEditor();
            this.root.empty();

            const loader =
                this.createSection('Preload');

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

            titleInput.on(
                'input.preloadEditor',
                () => {
                    this.state.preloadTitle =
                        String(
                            titleInput.val() || ''
                        ).trim();

                    if (
                        this.state.dirty &&
                        this.state.mode === 'new'
                    ) {
                        this.saveDraft();
                    }
                }
            );

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

            const newButton =
                this.createButton(
                    'New',
                    '',
                    () => this.startNew()
                );

            const sampleButton =
                this.createButton(
                    'Load Sample Page',
                    '',
                    () => this.loadSamplePage()
                );

            loader.append(
                $('<div>')
                    .addClass(
                        'preload-editor-toolbar'
                    )
                    .append(
                        titleInput,
                        loadButton,
                        newButton,
                        sampleButton
                    )
            );

            this.root.append(loader);

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

            this.root.append(
                this.workspace
            );

            /*
             * Fixed footer
             *
             * Local draft status, normal status messages,
             * and save actions live here.
             */
            const footer =
                $('<div>')
                    .addClass(
                        'preload-editor-footer'
                    );

            this.localDraftStatus =
                $('<div>')
                    .addClass(
                        'preload-editor-local-draft-status is-empty'
                    )
                    .text(
                        'No local draft'
                    );

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

            footer.append(
                this.localDraftStatus,
                this.status
            );

            this.root.append(
                footer
            );

            this.updateLocalDraftIndicator();

            this.renderWelcome();
        },

        renderWelcome() {
            this.workspace.empty();

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

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

            const tips =
                $('<ul>')
                    .addClass(
                        'preload-editor-tips'
                    );

            [
                '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);
                }
            );

            section.append(tips);
            this.workspace.append(section);
        },

        setStatus(
            message,
            type
        ) {
            if (!this.status) {
                return;
            }

            this.status.removeClass(
                'is-error',
                'is-success',
                'is-warning',
                'is-loading'
            );

            if (type) {
                this.status.addClass(
                    'is-' + type
                );
            }

            this.status.text(
                message || ''
            );
        },

        // =========================================================
        // Load / create
        // =========================================================

        async loadExisting(title) {
            title =
                String(title || '').trim();

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

                return;
            }

            this.setStatus(
                'Loading preload...',
                'loading'
            );

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

                if (!page) {
                    const suggestions =
                        await this.findSimilarPreloads(
                            title
                        );

                    this.renderMissingPage(
                        title,
                        suggestions
                    );

                    this.setStatus(
                        'Preload not found.',
                        'warning'
                    );

                    return;
                }

                await this.loadDefinition(
                    page.title,
                    page.content
                );

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

                this.setStatus(
                    error.message ||
                        'Could not load preload.',
                    'error'
                );
            }
        },

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

            if (!query) {
                query = title;
            }

            try {
                const results =
                    await this.searchPages(query);

                return results.filter(
                    result =>
                        /\/Preload$/i.test(
                            result.title
                        )
                );
            } catch (error) {
                console.error(
                    'Preload Editor search:',
                    error
                );

                return [];
            }
        },

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

            const section =
                this.createSection(
                    'Preload not found'
                );

            $('<p>')
                .text(
                    'No preload exists at "' +
                    title +
                    '".'
                )
                .appendTo(section);

            if (suggestions.length) {
                $('<h3>')
                    .text('Similar preloads')
                    .appendTo(section);

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

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

                section.append(list);
            }

            section.append(
                this.createButton(
                    'Create this preload',
                    'preload-editor-button-primary',
                    () =>
                        this.startNew(title)
                )
            );

            this.workspace.append(section);
        },

        async startNew(title) {
            title =
                String(title || '').trim();

            this.state = {
                preloadTitle: title,
                preloadSource: '',
                preloadContent:
                    this.defaults.preload,

                config:
                    this.emptyConfig(),

                variables: ['PAGE'],
                conditions: [],
                selectedVariable: 'PAGE',
                deletedVariables: [],
                mode: 'new',
                dirty: true,
                dirtyPreload: true,
                dirtyConfig: true,

                localDraftSavedAt: null,
                localDraftAvailable: false
            };

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

            this.setStatus(
                'Creating a new preload.',
                'success'
            );
        },

        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) {
                return;
            }

            this.setStatus(
                'Loading sample page...',
                'loading'
            );

            try {
                const page =
                    await this.getPage(result);

                if (!page) {
                    throw new Error(
                        'Sample page not found: ' +
                        result
                    );
                }

                const sample =
                    this.convertSampleToPreload(
                        page.content
                    );

                this.state = {
                    preloadTitle: '',
                    preloadSource: '',
                    preloadContent: sample,

                    config:
                        this.emptyConfig(),

                    variables: ['PAGE'],
                    conditions: [],
                    selectedVariable: 'PAGE',
                    deletedVariables: [],
                    mode: 'new',
                    dirty: true,
                    dirtyPreload: true,
                    dirtyConfig: true,

                    localDraftSavedAt: null,
                    localDraftAvailable: false
                };

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

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

                this.setStatus(
                    error.message ||
                        'Could not load sample page.',
                    'error'
                );
            }
        },

        convertSampleToPreload(source) {
            let content =
                String(source || '').trim();

            const include =
                content.match(
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                );

            if (include) {
                content =
                    include[1].trim();
            }

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

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

        async loadDefinition(
            title,
            source
        ) {
            const content =
                this.extractIncludeonly(source);

            let config =
                this.emptyConfig();

            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';

            const configPage =
                await this.getPage(configTitle);

            if (configPage) {
                try {
                    config =
                        JSON.parse(
                            configPage.content
                        );
                } catch (error) {
                    throw new Error(
                        'The existing config.json contains invalid JSON: ' +
                        error.message
                    );
                }
            }

            const analysis =
                this.analyzePreload(content);

            this.state.deletedVariables = [];

            config =
                this.prepareConfigForVariables(
                    config,
                    analysis
                );

            this.state = {
                preloadTitle: title,
                preloadSource: source,
                preloadContent: source,
                config,

                variables:
                    analysis.variables,

                conditions:
                    analysis.conditions,

                selectedVariable:
                    analysis.variables.includes('PAGE')
                        ? 'PAGE'
                        : (
                            analysis.variables[0] ||
                            'PAGE'
                        ),

                deletedVariables: [],

                mode: 'edit',
                dirty: false,
                dirtyPreload: false,
                dirtyConfig: false,

                localDraftSavedAt: null,
                localDraftAvailable: false
            };

            this.clearDraft(title);
            this.renderEditor();
        },

        // =========================================================
        // Editor
        // =========================================================

        renderEditor() {
            this.destroyEditor();
            this.workspace.empty();

            const layout =
                $('<div>')
                    .addClass(
                        'preload-editor-layout'
                    );

            const left =
                $('<div>')
                    .addClass(
                        'preload-editor-left'
                    );

            const right =
                $('<div>')
                    .addClass(
                        'preload-editor-right'
                    );

            const editorSection =
                this.createSection(
                    'Preload',
                    'preload-editor-main-section'
                );

            const meta =
                $('<div>')
                    .addClass(
                        'preload-editor-meta'
                    );

            $('<strong>')
                .text(
                    this.state.preloadTitle ||
                    'New preload'
                )
                .appendTo(meta);

            if (
                this.state.mode === 'new'
            ) {
                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('NEW')
                    .appendTo(meta);
            }

            editorSection.append(meta);
            editorSection.append(
                this.renderEditorTools()
            );

            const textarea =
                this.textarea(
                    this.state.preloadContent
                )
                    .attr(
                        'id',
                        'preload-editor-content'
                    )
                    .addClass(
                        'preload-editor-content'
                    );

            const editorMount =
                $('<div>')
                    .addClass(
                        'preload-editor-codemirror-mount'
                    )
                    .append(textarea);

            editorSection.append(editorMount);
            left.append(editorSection);

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

            const analysisContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis'
                    );

            analysisSection.append(
                analysisContainer
            );

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

            const detectVariablesButton =
                this.createButton(
                    'Detect variables',
                    'preload-editor-button-primary preload-editor-detect-variables',
                    () =>
                        this.detectVariables()
                );

            const detectHelp =
                $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-detect-variables-help'
                    )
                    .text(
                        'Refresh the variable list after editing the preload.'
                    );

            variablesSection.append(
                $('<div>')
                    .addClass(
                        'preload-editor-variable-detect'
                    )
                    .append(
                        detectVariablesButton,
                        detectHelp
                    )
            );

            const variableContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variables'
                    );

            variablesSection.append(
                variableContainer
            );

            const configurationSection =
                this.createSection(
                    'Variable configuration'
                );

            /*
             * Import button lives at the top of the
             * variable configuration section so the
             * workflow is always easy to find.
             */
            const importDefinitionsButton =
                this.createButton(
                    'Import definitions',
                    'preload-editor-button-primary preload-editor-import-definitions',
                    () =>
                        this.importVariableDefinitions()
                );

            const importHelp =
                $('<div>')
                    .addClass(
                        'preload-editor-help preload-editor-import-definitions-help'
                    )
                    .text(
                        'Import variable definitions from another preload config.json. Existing definitions can be replaced.'
                    );

            const importControls =
                $('<div>')
                    .addClass(
                        'preload-editor-import-definition-controls'
                    )
                    .append(
                        importDefinitionsButton,
                        importHelp
                    );

            configurationSection.append(
                importControls
            );

            const configurationContainer =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-configuration'
                    );

            configurationSection.append(
                configurationContainer
            );

            const configSection =
                this.createSection(
                    'Configuration'
                );

            const configHelp =
                $('<div>')
                    .addClass(
                        'preload-editor-help'
                    )
                    .text(
                        'Review the exact config.json that will be uploaded to the wiki.'
                    );

            const reviewConfigButton =
                this.createButton(
                    'Review config.json',
                    '',
                    () =>
                        this.createConfigReviewDialog()
                );

            configSection.append(
                configHelp,
                reviewConfigButton
            );

            right.append(
                analysisSection,
                variablesSection,
                configurationSection,
                configSection
            );

            layout.append(
                left,
                right
            );

            const saveBar =
                $('<div>')
                    .addClass(
                        'preload-editor-save-bar'
                    );

            saveBar.append(
                this.createButton(
                    'Save Preload',
                    '',
                    () =>
                        this.savePreloadOnly()
                ),

                this.createButton(
                    'Save Config',
                    '',
                    () =>
                        this.saveConfigOnly()
                ),

                this.createButton(
                    'Save',
                    'preload-editor-button-primary',
                    () =>
                        this.saveAll()
                )
            );

            this.root
                .find('.preload-editor-footer')
                .append(saveBar);

            this.workspace.append(
                layout
            );

            this.reanalyze();

            this.renderVariables(
                variableContainer
            );

            this.renderAnalysis(
                analysisContainer
            );

            this.renderVariableConfiguration(
                configurationContainer
            );

            this.updateEditorTools();
            this.updateLocalDraftIndicator();

            this.mountCodeMirror(
                textarea.get(0)
            ).catch(
                error => {
                    console.error(
                        'Preload Editor CodeMirror:',
                        error
                    );

                    this.setStatus(
                        error.message ||
                            'Could not initialize CodeMirror.',
                        'error'
                    );
                }
            );
        },

        // =========================================================
        // Variable detection
        // =========================================================

        detectVariables() {
            const source =
                this.getEditorContent();

            this.state.preloadContent =
                source;

            const content =
                this.extractIncludeonly(
                    source
                );

            const analysis =
                this.analyzePreload(
                    content
                );

            /*
             * If a variable was previously deleted from
             * the configuration, detecting the current
             * preload again should allow it to come back.
             */
            if (
                Array.isArray(
                    this.state.deletedVariables
                )
            ) {
                const detected =
                    new Set(
                        analysis.variables
                            .map(
                                name =>
                                    String(
                                        name
                                    ).toUpperCase()
                            )
                    );

                this.state.deletedVariables =
                    this.state.deletedVariables
                        .filter(
                            name =>
                                !detected.has(
                                    String(
                                        name
                                    ).toUpperCase()
                                )
                        );
            } else {
                this.state.deletedVariables = [];
            }

            this.markDirty();

            this.reanalyze();

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

            this.saveDraft();

            this.setStatus(
                'Variables detected from the current preload.',
                'success'
            );
        },

        reanalyze() {
            const source =
                this.getEditorContent();

            const content =
                this.extractIncludeonly(source);

            const analysis =
                this.analyzePreload(
                    content
                );

            this.state.preloadContent =
                source;

            this.state.variables =
                analysis.variables;

            this.state.conditions =
                analysis.conditions;

            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );

            this.analysis =
                analysis;

            const available =
                ['PAGE'].concat(
                    analysis.variables.filter(
                        name =>
                            name !== 'PAGE'
                    )
                );

            if (
                !available.includes(
                    this.state.selectedVariable
                )
            ) {
                this.state.selectedVariable =
                    available[0] ||
                    'PAGE';
            }
        },

        // =========================================================
        // Analysis
        // =========================================================

        renderAnalysis(container) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const variableCount =
                analysis.variables.length;

            const conditionCount =
                analysis.conditions.length;

            const configuredCount =
                Object.keys(
                    this.state.config &&
                    this.state.config.fields
                        ? this.state.config.fields
                        : {}
                ).length;

            const summary =
                $('<div>')
                    .addClass(
                        'preload-editor-analysis-summary'
                    );

            const createMetric =
                (
                    value,
                    label
                ) =>
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-metric'
                        )
                        .append(
                            $('<strong>')
                                .addClass(
                                    'preload-editor-analysis-value'
                                )
                                .text(value),

                            $('<span>')
                                .addClass(
                                    'preload-editor-analysis-label'
                                )
                                .text(label)
                        );

            summary.append(
                createMetric(
                    variableCount,
                    variableCount === 1
                        ? 'variable'
                        : 'variables'
                ),

                createMetric(
                    conditionCount,
                    conditionCount === 1
                        ? 'condition marker'
                        : 'condition markers'
                ),

                createMetric(
                    configuredCount,
                    configuredCount === 1
                        ? 'configured field'
                        : 'configured fields'
                )
            );

            container.append(summary);

            if (
                analysis.conditionVariables.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-analysis-note'
                        )
                        .text(
                            'Condition variables: ' +
                            analysis.conditionVariables.join(', ')
                        )
                );
            }

            const validation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );

            if (validation.errors.length) {
                const errors =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-errors'
                        );

                validation.errors.forEach(
                    error => {
                        $('<li>')
                            .text(error)
                            .appendTo(errors);
                    }
                );

                container.append(errors);
            }

            if (validation.warnings.length) {
                const warnings =
                    $('<ul>')
                        .addClass(
                            'preload-editor-validation-warnings'
                        );

                validation.warnings.forEach(
                    warning => {
                        $('<li>')
                            .text(warning)
                            .appendTo(warnings);
                    }
                );

                container.append(warnings);
            }

            if (
                !validation.errors.length &&
                !validation.warnings.length
            ) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-validation-success'
                        )
                        .text(
                            'Preload and configuration are in sync.'
                        )
                );
            }
        },

        renderAnalysisBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-analysis'
                );

            if (
                container &&
                container.length
            ) {
                this.renderAnalysis(container);
            }
        },

        // =========================================================
        // Variables
        // =========================================================

        renderVariables(container) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const variables =
                analysis.variables.slice();

            const ordered =
                ['PAGE'];

            variables.forEach(
                name => {
                    if (
                        name !== 'PAGE' &&
                        !ordered.includes(name)
                    ) {
                        ordered.push(name);
                    }
                }
            );

            const configuredFields =
                this.state.config &&
                this.state.config.fields
                    ? Object.keys(
                        this.state.config.fields
                    )
                    : [];

            configuredFields.forEach(
                name => {
                    const upper =
                        name.toUpperCase();

                    if (
                        upper !== 'PAGE' &&
                        !ordered.includes(upper)
                    ) {
                        ordered.push(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);
                    }
                }
            );

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

            ordered.forEach(
                name => {
                    const isSelected =
                        name ===
                        this.state.selectedVariable;

                    const isUsed =
                        analysis.variables.includes(
                            name
                        );

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

                    const item =
                        $('<button>')
                            .attr({
                                type: 'button',
                                'data-variable':
                                    name
                            })
                            .addClass(
                                'preload-editor-variable-item'
                            )
                            .toggleClass(
                                'is-selected',
                                isSelected
                            )
                            .on(
                                'click.preloadEditor',
                                () =>
                                    this.selectVariable(
                                        name
                                    )
                            );

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

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

                    list.append(item);
                }
            );

            container.append(list);

            if (!ordered.length) {
                container.append(
                    $('<div>')
                        .addClass(
                            'preload-editor-empty'
                        )
                        .text(
                            'No variables found in the preload.'
                        )
                );
            }
        },

        renderVariablesBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variables'
                );

            if (
                container &&
                container.length
            ) {
                this.renderVariables(container);
            }
        },

        selectVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();

            if (!name) {
                return;
            }

            const available =
                ['PAGE'].concat(
                    (
                        this.analysis &&
                        this.analysis.variables
                            ? this.analysis.variables
                            : []
                    ).filter(
                        variable =>
                            variable !== 'PAGE'
                    )
                );

            const configuredUnused =
                this.state.config &&
                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;
            }

            this.state.selectedVariable =
                name;

            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
        },

        renderVariableConfigurationBySelector() {
            const container =
                this.root &&
                this.root.find(
                    '.preload-editor-variable-configuration'
                );

            if (
                container &&
                container.length
            ) {
                this.renderVariableConfiguration(
                    container
                );
            }
        },

        renderVariableConfiguration(container) {
            container.empty();

            const analysis =
                this.analysis ||
                this.analyzePreload(
                    this.extractIncludeonly(
                        this.getEditorContent()
                    )
                );

            const name =
                this.state.selectedVariable ||
                'PAGE';

            const fields =
                this.state.config &&
                this.state.config.fields
                    ? this.state.config.fields
                    : {};

            const field =
                fields[name];

            if (name === 'PAGE') {
                container.append(
                    this.createAutomaticPageCard(
                        field ||
                        this.getAutomaticPageField()
                    )
                );

                return;
            }

            if (!field) {
                const missing =
                    $('<div>')
                        .addClass(
                            'preload-editor-variable preload-editor-variable-missing'
                        );

                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    )
                    .append(
                        $('<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 =
                $('<div>')
                    .addClass(
                        'preload-editor-variable preload-editor-variable-automatic'
                    )
                    .attr(
                        'data-variable',
                        'PAGE'
                    );

            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );

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

                $('<span>')
                    .addClass(
                        'preload-editor-badge'
                    )
                    .text('AUTOMATIC')
            );

            card.append(header);

            $('<p>')
                .addClass(
                    'preload-editor-analysis-note'
                )
                .text(
                    'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
                )
                .appendTo(card);

            const label =
                this.textInput(
                    field.label ||
                    'Page Name'
                ).prop(
                    'disabled',
                    true
                );

            const description =
                this.textarea(
                    field.description ||
                    'Name of the page.'
                )
                    .addClass(
                        'preload-editor-small-textarea'
                    )
                    .prop(
                        'disabled',
                        true
                    );

            const type =
                this.textInput(
                    field.type || 'page'
                ).prop(
                    'disabled',
                    true
                );

            const required =
                this.checkbox(
                    field.required !== false
                ).prop(
                    'disabled',
                    true
                );

            const namespace =
                this.textInput(
                    field.namespace || 'Main'
                ).prop(
                    'disabled',
                    true
                );

            const allowNew =
                this.checkbox(
                    field['allow-new'] === true
                ).prop(
                    'disabled',
                    true
                );

            card.append(
                this.fieldRow(
                    'Label',
                    label
                ),

                this.fieldRow(
                    'Description',
                    description
                ),

                this.fieldRow(
                    'Type',
                    type
                ),

                this.fieldRow(
                    'Required',
                    required
                ),

                this.fieldRow(
                    'Namespace',
                    namespace
                ),

                this.fieldRow(
                    'Allow new page',
                    allowNew
                )
            );

            return card;
        },

        createVariableCard(
            name,
            field,
            analysis
        ) {
            const card =
                $('<div>')
                    .addClass(
                        'preload-editor-variable'
                    )
                    .attr(
                        'data-variable',
                        name
                    );

            const header =
                $('<div>')
                    .addClass(
                        'preload-editor-variable-header'
                    );

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

            if (
                !analysis.variables.includes(name)
            ) {
                header.append(
                    $('<span>')
                        .addClass(
                            'preload-editor-badge is-warning'
                        )
                        .text('UNUSED')
                );
            }

            card.append(header);

            const label =
                this.textInput(
                    field.label || ''
                );

            label.on(
                'input.preloadEditor',
                () => {
                    field.label =
                        label.val();

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Label',
                    label
                )
            );

            const description =
                this.textarea(
                    field.description || ''
                ).addClass(
                    'preload-editor-small-textarea'
                );

            description.on(
                'input.preloadEditor',
                () => {
                    field.description =
                        description.val();

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Description',
                    description
                )
            );

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

            const typeSelect =
                this.select(
                    types.map(
                        ([value, label]) => ({
                            value,
                            label
                        })
                    ),
                    field.type || 'text'
                );

            typeSelect.on(
                'change.preloadEditor',
                () => {
                    field.type =
                        typeSelect.val();

                    this.markDirty();

                    this.renderVariableConfigurationBySelector();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Type',
                    typeSelect
                )
            );

            const required =
                this.checkbox(
                    field.required === true
                );

            required.on(
                'change.preloadEditor',
                () => {
                    field.required =
                        required.prop('checked');

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Required',
                    required
                )
            );

            const placeholder =
                this.textInput(
                    field.placeholder || ''
                );

            placeholder.on(
                'input.preloadEditor',
                () => {
                    const value =
                        placeholder.val();

                    if (value) {
                        field.placeholder =
                            value;
                    } else {
                        delete field.placeholder;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Placeholder',
                    placeholder
                )
            );

            const defaultInput =
                this.textInput(
                    field.default === undefined
                        ? ''
                        : field.default
                );

            defaultInput.on(
                'input.preloadEditor',
                () => {
                    const value =
                        defaultInput.val();

                    if (value) {
                        field.default =
                            value;
                    } else {
                        delete field.default;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Default',
                    defaultInput,
                    'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
                )
            );

            if (
                field.type === 'textarea'
            ) {
                const maxRows =
                    this.textInput(
                        field.maxRows || ''
                    ).attr(
                        'type',
                        'number'
                    );

                maxRows.on(
                    'input.preloadEditor',
                    () => {
                        const value =
                            parseInt(
                                maxRows.val(),
                                10
                            );

                        if (
                            Number.isFinite(value) &&
                            value > 0
                        ) {
                            field.maxRows =
                                value;
                        } else {
                            delete field.maxRows;
                        }

                        this.markDirty();
                        this.renderConfigReviewAvailability();
                    }
                );

                card.append(
                    this.fieldRow(
                        'Maximum rows',
                        maxRows
                    )
                );
            }

            if (
                field.type === 'select' ||
                field.type === 'multiselect'
            ) {
                this.renderSelectConfiguration(
                    card,
                    field
                );
            }

            if (
                field.type === 'file'
            ) {
                this.renderFileConfiguration(
                    card,
                    field
                );
            }

            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;
                        }

                        this.deleteVariable(name);
                    }
                );

            card.append(
                $('<div>')
                    .addClass(
                        'preload-editor-variable-delete'
                    )
                    .append(deleteButton)
            );

            return card;
        },

        async deleteVariable(name) {
            name =
                String(name || '')
                    .trim()
                    .toUpperCase();

            if (
                !name ||
                name === 'PAGE'
            ) {
                return;
            }

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

            delete this.state.config.fields[name];

            if (
                !Array.isArray(
                    this.state.deletedVariables
                )
            ) {
                this.state.deletedVariables = [];
            }

            if (
                !this.state.deletedVariables.includes(
                    name
                )
            ) {
                this.state.deletedVariables.push(
                    name
                );
            }

            this.state.selectedVariable =
                name;

            this.markDirty();

            this.renderVariablesBySelector();
            this.renderVariableConfigurationBySelector();
            this.renderAnalysisBySelector();
            this.renderConfigReviewAvailability();

            this.setStatus(
                'Variable "' +
                name +
                '" removed from the configuration.',
                'warning'
            );
        },

        // =========================================================
        // Select configuration
        // =========================================================

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

            source.on(
                'input.preloadEditor',
                () => {
                    const value =
                        source.val();

                    if (value) {
                        field.source = value;
                    } else {
                        delete field.source;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Source',
                    source,
                    'Currently supported source format: category:Category Name'
                )
            );

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

            sourcePrefix.on(
                'input.preloadEditor',
                () => {
                    const value =
                        sourcePrefix.val();

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

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            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) {
                        field.exclude =
                            Object.assign(
                                {},
                                field.exclude || {},
                                {
                                    category: value
                                }
                            );
                    } else if (
                        field.exclude
                    ) {
                        delete field.exclude.category;

                        if (
                            !Object.keys(
                                field.exclude
                            ).length
                        ) {
                            delete field.exclude;
                        }
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Exclude category',
                    excludeCategory
                )
            );

            card.append(
                this.createTransformControls(
                    field,
                    'value',
                    'Value transformation'
                ),

                this.createTransformControls(
                    field,
                    'label',
                    'Label transformation'
                )
            );

            const optionsValue =
                Array.isArray(field.options)
                    ? field.options
                        .map(
                            option => {
                                if (
                                    typeof option ===
                                    'string'
                                ) {
                                    return option;
                                }

                                return (
                                    String(
                                        option.value || ''
                                    ) +
                                    ' | ' +
                                    String(
                                        option.label ||
                                        option.value ||
                                        ''
                                    )
                                );
                            }
                        )
                        .join('\n')
                    : '';

            const options =
                this.textarea(
                    optionsValue
                ).addClass(
                    'preload-editor-small-textarea'
                );

            options.on(
                'input.preloadEditor',
                () => {
                    const lines =
                        options
                            .val()
                            .split('\n')
                            .map(
                                line =>
                                    line.trim()
                            )
                            .filter(Boolean);

                    if (!lines.length) {
                        delete field.options;
                    } else {
                        field.options =
                            lines.map(
                                line => {
                                    const parts =
                                        line.split(
                                            /\s*\|\s*/
                                        );

                                    if (
                                        parts.length ===
                                        1
                                    ) {
                                        return parts[0];
                                    }

                                    return {
                                        value:
                                            parts[0],

                                        label:
                                            parts
                                                .slice(1)
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Manual options',
                    options,
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                )
            );
        },
        
                renderFileConfiguration(
            card,
            field
        ) {
            const acceptValue =
                Array.isArray(field.accept)
                    ? field.accept.join('\n')
                    : '';

            const accept =
                this.textarea(
                    acceptValue
                ).addClass(
                    'preload-editor-small-textarea'
                );

            accept.on(
                'input.preloadEditor',
                () => {
                    const values =
                        accept
                            .val()
                            .split('\n')
                            .map(
                                value =>
                                    value.trim()
                            )
                            .filter(Boolean);

                    if (values.length) {
                        field.accept = values;
                    } else {
                        delete field.accept;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Accepted MIME types',
                    accept,
                    'One MIME type per line. Example: image/png'
                )
            );

            const filename =
                this.textInput(
                    field.filename ||
                    '%%PAGE%%.%%EXT%%'
                );

            filename.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filename.val();

                    if (value) {
                        field.filename = value;
                    } else {
                        delete field.filename;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'Filename',
                    filename,
                    'Filename template. %%PAGE%% is replaced with the page name and %%EXT%% with the uploaded file extension.'
                )
            );

            const filetext =
                this.textarea(
                    field.filetext || ''
                );

            filetext.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filetext.val();

                    if (value) {
                        field.filetext = value;
                    } else {
                        delete field.filetext;
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );

            card.append(
                this.fieldRow(
                    'File page text',
                    filetext,
                    'Wikitext used for the uploaded File: page.'
                )
            );
        },

        createTransformControls(
            field,
            property,
            title
        ) {
            const wrapper =
                $('<div>')
                    .addClass(
                        'preload-editor-transform'
                    );

            $('<h4>')
                .text(title)
                .appendTo(wrapper);

            const existing =
                field[property] &&
                typeof field[property] === 'object'
                    ? field[property]
                    : {};

            const transform =
                this.select(
                    [
                        {
                            value: '',
                            label:
                                'No transformation'
                        },
                        {
                            value:
                                'remove-prefix',
                            label:
                                'Remove prefix'
                        },
                        {
                            value:
                                'remove-suffix',
                            label:
                                'Remove suffix'
                        },
                        {
                            value: 'replace',
                            label:
                                'Replace'
                        }
                    ],
                    existing.transform || ''
                );

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

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

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

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

            const controls =
                $('<div>')
                    .addClass(
                        'preload-editor-transform-controls'
                    );

            const prefixRow =
                this.fieldRow(
                    'Prefix',
                    prefix
                );

            const suffixRow =
                this.fieldRow(
                    'Suffix',
                    suffix
                );

            const fromRow =
                this.fieldRow(
                    'From',
                    from
                );

            const toRow =
                this.fieldRow(
                    'To',
                    to
                );

            const renderInputs =
                () => {
                    const type =
                        transform.val();

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

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

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

                    toRow.toggle(
                        type === 'replace'
                    );
                };

            const update =
                () => {
                    const type =
                        transform.val();

                    let object = null;

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

                    if (object) {
                        field[property] = object;
                    } else {
                        delete field[property];
                    }

                    this.markDirty();
                    this.renderConfigReviewAvailability();
                };

            transform.on(
                'change.preloadEditor',
                () => {
                    update();
                    renderInputs();
                }
            );

            [
                prefix,
                suffix,
                from,
                to
            ].forEach(
                input => {
                    input.on(
                        'input.preloadEditor',
                        update
                    );
                }
            );

            controls.append(
                this.fieldRow(
                    'Transformation',
                    transform
                ),

                prefixRow,
                suffixRow,
                fromRow,
                toRow
            );

            renderInputs();

            wrapper.append(controls);

            return wrapper;
        },

        // =========================================================
        // Config output
        // =========================================================

        getCleanConfig() {
            const config =
                JSON.parse(
                    JSON.stringify(
                        this.state.config
                    )
                );

            const fields =
                config.fields || {};

            const orderedFields = {};

            orderedFields.PAGE =
                this.getAutomaticPageField();

            Object.keys(fields)
                .filter(
                    name =>
                        name.toUpperCase() !== 'PAGE'
                )
                .forEach(
                    name => {
                        orderedFields[name] =
                            fields[name];
                    }
                );

            config.fields =
                orderedFields;

            return config;
        },

        getConfigJSON() {
            return JSON.stringify(
                this.getCleanConfig(),
                null,
                4
            );
        },

        renderConfigPreview(container) {
            if (container) {
                container.text(
                    this.getConfigJSON()
                );
            }
        },

        renderConfigPreviewBySelector() {
            this.renderAnalysisBySelector();
        },

        renderConfigReviewAvailability() {
            this.renderAnalysisBySelector();
        },

        // =========================================================
        // Dirty state
        // =========================================================

        updateDirtyState() {
            this.state.dirty =
                !!(
                    this.state.dirtyPreload ||
                    this.state.dirtyConfig
                );
        },

        markPreloadDirty() {
            this.state.dirtyPreload = true;
            this.updateDirtyState();
            this.saveDraft();
        },

        markConfigDirty() {
            this.state.dirtyConfig = true;
            this.updateDirtyState();
            this.saveDraft();
        },

        markDirty() {
            this.state.dirtyPreload = true;
            this.state.dirtyConfig = true;
            this.updateDirtyState();
            this.saveDraft();
        },

        // =========================================================
        // Save
        // =========================================================

        async savePreloadOnly() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();

            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );

                if (!result) {
                    return;
                }

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

            this.state.preloadContent =
                this.getEditorContent();

            const validation =
                this.validateBeforeSave();

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

                this.saveDraft();
                return;
            }

            const confirmed =
                await this.confirmSave(
                    'preload',
                    title
                );

            if (!confirmed) {
                return;
            }

            this.setStatus(
                'Saving preload...',
                'loading'
            );

            try {
                const exists =
                    await this.pageExists(title);

                await this.savePage(
                    title,
                    this.state.preloadContent,
                    exists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !exists
                );

                this.state.mode = 'edit';
                this.state.preloadTitle = title;
                this.state.dirtyPreload = false;

                this.updateDirtyState();

                if (!this.state.dirty) {
                    this.clearDraft(title);
                } else {
                    this.saveDraft();
                }

                this.setStatus(
                    this.state.dirtyConfig
                        ? 'Preload saved. Config still has unsaved changes.'
                        : 'Preload saved successfully.',
                    'success'
                );

                mw.notify(
                    this.state.dirtyConfig
                        ? 'Preload saved. Config still has unsaved changes.'
                        : 'Preload saved.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                this.saveDraft();

                this.setStatus(
                    error.message ||
                        'Could not save preload.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not save preload.',
                    {
                        type: 'error'
                    }
                );
            }
        },

        async saveConfigOnly() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();

            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );

                if (!result) {
                    return;
                }

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

            this.state.preloadContent =
                this.getEditorContent();

            this.reanalyze();

            const validation =
                this.validateBeforeSave();

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

                this.saveDraft();
                return;
            }

            const confirmed =
                await this.confirmSave(
                    'config',
                    title
                );

            if (!confirmed) {
                return;
            }

            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';

            this.setStatus(
                'Saving config...',
                'loading'
            );

            try {
                /*
                 * This now automatically creates or converts
                 * config.json to the JSON content model.
                 */
                await this.saveConfigPage(
                    configTitle,
                    this.getConfigJSON(),
                    (
                        await this.pageExists(
                            configTitle
                        )
                    )
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor'
                );

                this.state.mode = 'edit';
                this.state.preloadTitle = title;
                this.state.dirtyConfig = false;

                this.updateDirtyState();

                if (!this.state.dirty) {
                    this.clearDraft(title);
                } else {
                    this.saveDraft();
                }

                this.setStatus(
                    this.state.dirtyPreload
                        ? 'Config saved as JSON. Preload still has unsaved changes.'
                        : 'Config saved as JSON successfully.',
                    'success'
                );

                mw.notify(
                    this.state.dirtyPreload
                        ? 'Config saved as JSON. Preload still has unsaved changes.'
                        : 'Config saved as JSON.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor config save:',
                    error
                );

                this.saveDraft();

                this.setStatus(
                    error.message ||
                        'Could not save config.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not save config.',
                    {
                        type: 'error'
                    }
                );
            }
        },

        async saveAll() {
            let title =
                String(
                    this.state.preloadTitle || ''
                ).trim();

            if (!title) {
                const result =
                    await this.createPromptDialog(
                        'Preload title',
                        'Enter the full title for the preload.'
                    );

                if (!result) {
                    return;
                }

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

            this.state.preloadContent =
                this.getEditorContent();

            this.reanalyze();

            const validation =
                this.validateBeforeSave();

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

                this.saveDraft();
                return;
            }

            const confirmed =
                await this.confirmSave(
                    'both',
                    title
                );

            if (!confirmed) {
                return;
            }

            const configTitle =
                title.replace(
                    /\/+$/,
                    ''
                ) +
                '/config.json';

            this.setStatus(
                'Saving preload and config...',
                'loading'
            );

            try {
                const preloadExists =
                    await this.pageExists(title);

                await this.savePage(
                    title,
                    this.state.preloadContent,
                    preloadExists
                        ? 'Updated preload with Preload Editor'
                        : 'Created preload with Preload Editor',
                    !preloadExists
                );

                /*
                 * Config is saved through the JSON-aware
                 * helper. This means:
                 *
                 * - new config -> created as JSON
                 * - old wikitext config -> converted to JSON
                 * - existing JSON config -> simply updated
                 */
                const configExists =
                    await this.pageExists(
                        configTitle
                    );

                await this.saveConfigPage(
                    configTitle,
                    this.getConfigJSON(),
                    configExists
                        ? 'Updated preload config with Preload Editor'
                        : 'Created preload config with Preload Editor'
                );

                this.state.mode = 'edit';
                this.state.dirtyPreload = false;
                this.state.dirtyConfig = false;
                this.state.dirty = false;
                this.state.preloadTitle = title;

                this.clearDraft(title);

                this.setStatus(
                    'Preload and JSON config saved successfully.',
                    'success'
                );

                mw.notify(
                    'Preload and config saved. config.json is using the JSON content model.',
                    {
                        type: 'success'
                    }
                );
            } catch (error) {
                console.error(
                    'Preload Editor save:',
                    error
                );

                this.saveDraft();

                this.setStatus(
                    error.message ||
                        'Could not save preload/config.',
                    'error'
                );

                mw.notify(
                    error.message ||
                        'Could not save preload/config.',
                    {
                        type: 'error'
                    }
                );
            }
        },

        // =========================================================
        // Validation
        // =========================================================

        validateBeforeSave() {
            const errors = [];
            const warnings = [];

            const title =
                String(
                    this.state.preloadTitle || ''
                ).trim();

            if (!title) {
                errors.push(
                    'A preload title is required.'
                );
            }

            if (
                title &&
                !/\/Preload$/i.test(title)
            ) {
                warnings.push(
                    'The title does not end with "/Preload".'
                );
            }

            const content =
                this.getEditorContent();

            this.state.preloadContent =
                content;

            if (!content.trim()) {
                errors.push(
                    'The preload cannot be empty.'
                );
            }

            if (
                !/<includeonly>/i.test(content)
            ) {
                warnings.push(
                    'The preload does not contain an <includeonly> section.'
                );
            }

            if (
                !/<noinclude>/i.test(content)
            ) {
                warnings.push(
                    'The preload does not contain a <noinclude> section.'
                );
            }

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

            this.state.config =
                this.prepareConfigForVariables(
                    this.state.config ||
                        this.emptyConfig(),
                    analysis
                );

            const configValidation =
                this.validateConfig(
                    this.state.config,
                    analysis
                );

            errors.push(
                ...configValidation.errors
            );

            warnings.push(
                ...configValidation.warnings
            );

            let depth = 0;
                        analysis.conditions.forEach(
                condition => {
                    if (
                        condition.type === 'if'
                    ) {
                        depth++;
                    } else if (
                        condition.type === 'endif'
                    ) {
                        depth--;

                        if (depth < 0) {
                            errors.push(
                                'Found @endif without a matching @if.'
                            );

                            depth = 0;
                        }
                    } else if (
                        condition.type === 'elseif' ||
                        condition.type === 'else'
                    ) {
                        if (depth === 0) {
                            errors.push(
                                '@' +
                                condition.type +
                                ' exists outside an @if block.'
                            );
                        }
                    }
                }
            );

            if (depth > 0) {
                errors.push(
                    'One or more @if blocks are missing @endif.'
                );
            }

            return {
                errors,
                warnings,
                valid:
                    errors.length === 0
            };
        },

        showValidationErrors(validation) {
            this.renderAnalysisBySelector();

            this.saveDraft();

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

            mw.notify(
                validation.errors.join('\n'),
                {
                    type: 'error'
                }
            );
        }
    };

    mw.PreloadEditor =
        PreloadEditor;

    $(function () {
        if (
            mw.config.get('wgPageName') !==
            'Petit_Planet_Wiki:Preload_Editor'
        ) {
            return;
        }

        PreloadEditor.init();
    });

}(mediaWiki, jQuery));