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

MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

MediaWiki interface page
ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
Line 1: Line 1:
!function(e,t){"use strict";const a={version:"1.2.0",state:{preloadTitle:"",preloadSource:"",preloadContent:"",config:null,variables:[],conditions:[],selectedVariable:"PAGE",deletedVariables:[],mode:"idle",dirty:!1,dirtyPreload:!1,dirtyConfig:!1,localDraftSavedAt:null,localDraftAvailable:!1},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"},
(function (mw, $) {
    'use strict';


getDraftStorage(){try{const e=window.localStorage.getItem(this.draftStorageKey);if(!e)return{active:null,drafts:{}};const t=JSON.parse(e);return!t||"object"!=typeof t||Array.isArray(t)?{active:null,drafts:{}}:(t.drafts&&"object"==typeof t.drafts&&!Array.isArray(t.drafts)||(t.drafts={}),t)}catch(e){return console.warn("Preload Editor: Could not read local drafts.",e),{active:null,drafts:{}}}},
    const PreloadEditor = {
        version: '1.2.0',


setDraftStorage(e){try{return window.localStorage.setItem(this.draftStorageKey,JSON.stringify(e)),!0}catch(e){return console.warn("Preload Editor: Could not save local draft.",e),!1}},
        state: {
            preloadTitle: '',
            preloadSource: '',
            preloadContent: '',
            config: null,
            variables: [],
            conditions: [],
            selectedVariable: 'PAGE',
            deletedVariables: [],
            mode: 'idle',
            dirty: false,
            dirtyPreload: false,
            dirtyConfig: false,


getDraftId:e=>(e=String(e||"").trim())?e.toLowerCase():"__untitled__",
            // Local draft state
            localDraftSavedAt: null,
            localDraftAvailable: false
        },


getDraftSnapshot(){let e=this.getEditorContent();this.editorView||e||(e=this.state.preloadContent||"");let t=null;try{t=this.state.config?JSON.parse(JSON.stringify(this.state.config)):this.emptyConfig()}catch(e){console.warn("Preload Editor: Could not clone config for local draft.",e),t=this.emptyConfig()}return{version:4,preloadTitle:String(this.state.preloadTitle||""),preloadSource:String(this.state.preloadSource||""),preloadContent:String(e||""),config:t,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()}},
        editor: null,
        editorView: null,
        analysis: null,
        root: null,
        workspace: null,
        status: null,
        localDraftStatus: null,
        editorTools: null,


updateLocalDraftStatus(e,t){this.localDraftStatus&&(this.localDraftStatus.removeClass("is-saved","is-warning","is-error","is-empty"),t&&this.localDraftStatus.addClass("is-"+t),this.localDraftStatus.text(e||""))},
        draftStorageKey: 'PetitPlanet.PreloadEditor.drafts.v1',


formatLocalDraftTime(e){if(!e)return"";try{return new Date(e).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit",second:"2-digit"})}catch(e){return""}},
        defaults: {
            preload: '<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
        },


updateLocalDraftIndicator(){if(this.localDraftStatus){if(this.state.localDraftSavedAt){const e=this.formatLocalDraftTime(this.state.localDraftSavedAt);return void this.updateLocalDraftStatus(e?"Saved locally at "+e:"Saved locally","saved")}this.updateLocalDraftStatus("No local draft","empty")}},
        // =========================================================
        // Browser-local draft storage
        // =========================================================


saveDraft(){if(!this.state.dirty)return!1;const e=this.getDraftSnapshot(),t=this.getDraftId(e.preloadTitle),a=this.getDraftStorage();a.drafts[t]=e,a.active=t;const i=this.setDraftStorage(a);return i?(this.state.localDraftSavedAt=e.savedAt,this.state.localDraftAvailable=!0,this.updateLocalDraftIndicator()):(this.state.localDraftAvailable=!1,this.updateLocalDraftStatus("Could not save local draft","error")),i},
        getDraftStorage() {
            try {
                const raw = window.localStorage.getItem(this.draftStorageKey);


clearDraft(e){const t=this.getDraftStorage(),a=this.getDraftId(e);t.drafts&&t.drafts[a]&&delete t.drafts[a],t.active===a&&(t.active=null),this.setDraftStorage(t),this.state.localDraftSavedAt=null,this.state.localDraftAvailable=!1,this.updateLocalDraftIndicator()},
                if (!raw) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }


getMostRecentDraft(){const e=this.getDraftStorage();if(!e.drafts||!Object.keys(e.drafts).length)return null;if(e.active&&e.drafts[e.active])return{id:e.active,draft:e.drafts[e.active]};let t=null,a=null;return Object.entries(e.drafts).forEach(([e,i])=>{i&&"object"==typeof i&&(!a||(Number(i.savedAt)||0)>(Number(a.savedAt)||0))&&(t=e,a=i)}),a?{id:t,draft:a}:null},
                const parsed = JSON.parse(raw);


async restoreDraft(){const t=this.getMostRecentDraft();if(!t||!t.draft)return!1;const a=t.draft,i=String(a.preloadTitle||"").trim(),r=i||"an untitled preload",o=a.savedAt?new Date(a.savedAt).toLocaleString():"",s="Preload Editor found unsaved local work for "+r+(o?"\n\nLast saved locally: "+o:"")+"\n\nRestore it?";if(!await this.createConfirmDialog("Restore unsaved work",s,"Restore","Discard"))return this.clearDraft(i),!1;let n=this.emptyConfig();if(a.config&&"object"==typeof a.config&&!Array.isArray(a.config))try{n=this.normalizeConfig(JSON.parse(JSON.stringify(a.config)))}catch(e){console.warn("Preload Editor: Could not restore local config.",e)}const d="boolean"==typeof a.dirtyPreload||"boolean"==typeof a.dirtyConfig;return this.state={preloadTitle:i,preloadSource:String(a.preloadSource||""),preloadContent:String(a.preloadContent||""),config:n,variables:Array.isArray(a.variables)?a.variables.slice():[],conditions:Array.isArray(a.conditions)?a.conditions.slice():[],selectedVariable:String(a.selectedVariable||"PAGE"),deletedVariables:Array.isArray(a.deletedVariables)?a.deletedVariables.map(e=>String(e).toUpperCase()).filter(e=>"PAGE"!==e):[],mode:a.mode||"edit",dirty:!d||!(!a.dirtyPreload&&!a.dirtyConfig),dirtyPreload:!d||!!a.dirtyPreload,dirtyConfig:!d||!!a.dirtyConfig,localDraftSavedAt:Number(a.savedAt)||null,localDraftAvailable:!0},this.reanalyze(),this.renderEditor(),this.updateLocalDraftIndicator(),this.setStatus("Restored unsaved local work from this browser.","warning"),e.notify("Unsaved Preload Editor work restored.",{type:"success"}),!0},
                if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
                    return {
                        active: null,
                        drafts: {}
                    };
                }


async getPage(t){const a=await(new e.Api).get({action:"query",prop:"info|revisions",titles:t,rvprop:"content",rvslots:"main",formatversion:2}),i=a&&a.query&&a.query.pages&&a.query.pages[0];if(!i||i.missing)return null;const r=i.revisions&&i.revisions[0]&&i.revisions[0].slots&&i.revisions[0].slots.main&&i.revisions[0].slots.main.content;return{title:i.title||t,content:"string"==typeof r?r:"",contentmodel:i.contentmodel||"wikitext"}},
                if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
                    parsed.drafts = {};
                }


async pageExists(e){return!!await this.getPage(e)},
                return parsed;
            } catch (error) {
                console.warn('Preload Editor: Could not read local drafts.', error);


async searchPages(t){const a=await(new e.Api).get({action:"query",list:"search",srsearch:t,srlimit:10,srnamespace:10,formatversion:2});return a&&a.query&&a.query.search||[]},
                return {
                    active: null,
                    drafts: {}
                };
            }
        },


async savePage(t,a,i,r,o){const s={action:"edit",title:t,text:a,summary:i||"Updated with Preload Editor",formatversion:2};return r&&(s.createonly=!0),o&&(s.contentmodel=o),(new e.Api).postWithToken("csrf",s)},
        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;
            }
        },


changeContentModel:async(t,a,i)=>(new e.Api).postWithToken("csrf",{action:"changecontentmodel",title:t,model:a,reason:i||"Changed content model with Preload Editor"}),
        getDraftId(title) {
            title = String(title || '').trim();
            return title ? title.toLowerCase() : '__untitled__';
        },


async saveConfigPage(e,t,a){const i=await this.getPage(e);return i?("json"!==String(i.contentmodel||"").toLowerCase()&&await this.changeContentModel(e,"json","Changed preload config content model to JSON"),this.savePage(e,t,a,!1)):this.savePage(e,t,a,!0,"json")},
        getDraftSnapshot() {
            let content = this.getEditorContent();


normalizeConfigTitle:e=>(e=(e=String(e||"").trim()).replace(/\/+$/,""),/\/config\.json$/i.test(e)?e:e+"/config.json"),
            if (!this.editorView && !content) {
                content = this.state.preloadContent || '';
            }


cloneConfigField(e){try{return JSON.parse(JSON.stringify(e))}catch(e){return console.warn("Preload Editor: Could not clone imported field.",e),null}},
            let config = null;


getImportableFields(e){const t=e&&e.fields;if(!t||"object"!=typeof t||Array.isArray(t))return{};const a={};return Object.entries(t).forEach(([e,t])=>{const i=String(e||"").trim().toUpperCase();if(!i||"PAGE"===i)return;if(!t||"object"!=typeof t||Array.isArray(t))return;const r=this.cloneConfigField(t);r&&(a[i]=r)}),a},
            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();
            }


createImportDefinitionsDialog(e,a){return new Promise(i=>{const r=Object.keys(a).sort((e,t)=>e.localeCompare(t)),o=t("<div>").addClass("preload-editor-prompt-overlay preload-editor-import-overlay"),s=t("<div>").addClass("preload-editor-prompt preload-editor-import-dialog");if(t("<h2>").text("Import variable definitions").appendTo(s),t("<p>").text('Choose which variable definitions to import from "'+e+'". Existing local definitions can be replaced.').appendTo(s),!r.length){t("<div>").addClass("preload-editor-empty preload-editor-import-empty").text("This config.json does not contain any importable variable definitions.").appendTo(s);const e=t("<div>").addClass("preload-editor-prompt-buttons"),a=this.createButton("Close","preload-editor-button-primary",()=>{o.remove(),i(null)});return e.append(a),s.append(e),o.append(s),t(document.body).append(o),o.attr("tabindex","-1"),void o.trigger("focus")}const n=this.textInput("","Search variables...").addClass("preload-editor-import-search"),d=t("<div>").addClass("preload-editor-import-search-wrap").append(n);s.append(d);const l=t("<div>").addClass("preload-editor-import-controls"),c=this.createButton("Select all","",()=>{h.find('input[type="checkbox"]:visible').prop("checked",!0),g()}),p=this.createButton("Deselect all","",()=>{h.find('input[type="checkbox"]:visible').prop("checked",!1),g()});l.append(c,p),s.append(l);const h=t("<div>").addClass("preload-editor-import-list"),f=this.state.config&&this.state.config.fields?this.state.config.fields:{};r.forEach(e=>{const i=!!f[e],r=a[e],o=r&&r.type?String(r.type):"text",s=r&&r.label?String(r.label):this.humanizeName(e),n=t("<label>").addClass("preload-editor-import-row").attr("data-variable",e).attr("data-search",(e+" "+s+" "+o).toLowerCase()),d=this.checkbox(!1).attr("data-import-variable",e).addClass("preload-editor-import-checkbox"),l=t("<span>").addClass("preload-editor-import-content"),c=t("<span>").addClass("preload-editor-import-header");t("<strong>").text(e).appendTo(c),t("<span>").addClass("preload-editor-import-type").text(o).appendTo(c);const p=t("<span>").addClass("preload-editor-import-meta");t("<span>").text(s).appendTo(p);const u=t("<span>").addClass("preload-editor-import-status").toggleClass("is-existing",i).toggleClass("is-new",!i).text(i?"REPLACE EXISTING":"NEW");l.append(c,p,u),n.append(d,l),h.append(n)}),s.append(h);const u=t("<div>").addClass("preload-editor-import-summary"),g=()=>{const e=h.find('input[type="checkbox"]:checked').length,a=h.find('input[type="checkbox"]:checked').filter(function(){const e=t(this).attr("data-import-variable");return!!f[e]}).length;let i=e+" "+(1===e?"variable":"variables")+" selected.";a&&(i+=" "+a+" existing "+(1===a?"definition":"definitions")+" will be replaced."),u.text(i),m.prop("disabled",0===e)};n.on("input.preloadEditor",()=>{const e=String(n.val()||"").trim().toLowerCase();h.find(".preload-editor-import-row").each(function(){const a=t(this),i=String(a.attr("data-search")||"");a.toggle(!e||i.includes(e))})}),h.on("change.preloadEditor",'input[type="checkbox"]',g);const y=t("<div>").addClass("preload-editor-prompt-buttons"),v=this.createButton("Cancel","",()=>{o.remove(),i(null)}),m=this.createButton("Import selected","preload-editor-button-primary",()=>{const e=[];h.find('input[type="checkbox"]:checked').each(function(){e.push(String(t(this).attr("data-import-variable")))}),o.remove(),i(e)});y.append(v,m),s.append(u,y),o.append(s),t(document.body).append(o),o.attr("tabindex","-1"),o.trigger("focus"),g(),n.trigger("focus"),o.on("keydown.preloadEditor",e=>{"Escape"===e.key&&v.trigger("click")}),o.on("click.preloadEditor",e=>{e.target===o.get(0)&&v.trigger("click")})})},
            return {
                version: 3,
                preloadTitle: String(this.state.preloadTitle || ''),
                preloadSource: String(this.state.preloadSource || ''),
                preloadContent: String(content || ''),
                config,


async importVariableDefinitions(){const t=await this.createPromptDialog("Import variable definitions","Enter a preload title or its config.json title.");if(!t)return;const a=this.normalizeConfigTitle(t);this.setStatus("Loading source config...","loading");try{const t=await this.getPage(a);if(!t)throw new Error('No config.json was found at "'+a+'".');let i;try{i=JSON.parse(t.content)}catch(e){throw new Error("The source config.json contains invalid JSON: "+e.message)}if(!i||"object"!=typeof i||Array.isArray(i))throw new Error("The source config.json must contain a JSON object.");if(!i.fields||"object"!=typeof i.fields||Array.isArray(i.fields))throw new Error('The source config.json does not contain a valid "fields" object.');const r=this.getImportableFields(i),o=await this.createImportDefinitionsDialog(a,r);if(null===o)return void this.setStatus("Variable import cancelled.","warning");if(!o.length)return void this.setStatus("No variable definitions were imported.","warning");this.state.config||(this.state.config=this.emptyConfig()),this.state.config.fields||(this.state.config.fields={});const s=[],n=[];o.forEach(e=>{if("PAGE"===e)return;const t=r[e];if(!t)return;this.state.config.fields[e]?n.push(e):s.push(e);const a=this.cloneConfigField(t);a&&(this.state.config.fields[e]=a,Array.isArray(this.state.deletedVariables)&&(this.state.deletedVariables=this.state.deletedVariables.filter(t=>String(t).toUpperCase()!==e)))}),this.state.config.fields.PAGE=this.getAutomaticPageField(),this.markConfigDirty(),!!this.state.config.fields[this.state.selectedVariable]&&"PAGE"!==this.state.selectedVariable||(this.state.selectedVariable=o[0]||"PAGE"),this.reanalyze(),this.renderVariablesBySelector(),this.renderVariableConfigurationBySelector(),this.renderAnalysisBySelector(),this.updateEditorTools(),this.saveDraft();let d="Imported "+o.length+" variable "+(1===o.length?"definition":"definitions")+".";n.length&&(d+=" Replaced: "+n.join(", ")+"."),this.setStatus(d,"success"),e.notify(d,{type:"success"})}catch(t){console.error("Preload Editor variable import:",t),this.setStatus(t.message||"Could not import variable definitions.","error"),e.notify(t.message||"Could not import variable definitions.",{type:"error"})}},
                variables: Array.isArray(this.state.variables)
                    ? this.state.variables.slice()
                    : [],


extractIncludeonly(e){const t=String(e||"").match(/<includeonly>([\s\S]*?)<\/includeonly>/i);return t?t[1].trim():String(e||"").trim()},
                conditions: Array.isArray(this.state.conditions)
                    ? this.state.conditions.slice()
                    : [],


findVariables(e){const t=[],a=/%%([A-Za-z0-9_-]+)%%/g;let i;for(;null!==(i=a.exec(e));){const e=i[1].toUpperCase();t.includes(e)||t.push(e)}return t},
                selectedVariable: String(this.state.selectedVariable || 'PAGE'),


findConditions(e){const t=[],a=/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;let i;for(;null!==(i=a.exec(e));)t.push({type:i[1].toLowerCase(),expression:i[2].trim(),index:i.index});return t},
                deletedVariables: Array.isArray(this.state.deletedVariables)
                    ? this.state.deletedVariables.slice()
                    : [],


findConditionVariables(e){const t=[];return this.findConditions(e).forEach(e=>{if("if"!==e.type&&"elseif"!==e.type)return;(e.expression.match(/[A-Za-z0-9_-]+/g)||[]).forEach(e=>{e=e.toUpperCase(),t.includes(e)||t.push(e)})}),t},
                mode: this.state.mode || 'edit',
                dirty: !!this.state.dirty,
                dirtyPreload: !!this.state.dirtyPreload,
                dirtyConfig: !!this.state.dirtyConfig,
                savedAt: Date.now()
            };
        },


analyzePreload(e){return{variables:this.findVariables(e),conditions:this.findConditions(e),conditionVariables:this.findConditionVariables(e)}},
        updateLocalDraftStatus(message, type) {
            if (!this.localDraftStatus) {
                return;
            }


getAutomaticPageField:()=>({label:"Page Name",description:"Name of the page.",type:"page",required:!0,namespace:"Main","allow-new":!0}),
            this.localDraftStatus.removeClass(
                'is-saved',
                'is-warning',
                'is-error',
                'is-empty'
            );


emptyConfig(){return{settings:{incomplete:"add-stub"},fields:{PAGE:this.getAutomaticPageField()}}},
            if (type) {
                this.localDraftStatus.addClass('is-' + type);
            }


normalizeConfig(e){return e&&"object"==typeof e&&!Array.isArray(e)||(e=this.emptyConfig()),e.settings&&"object"==typeof e.settings&&!Array.isArray(e.settings)||(e.settings={}),e.fields&&"object"==typeof e.fields&&!Array.isArray(e.fields)||(e.fields={}),e.fields.PAGE=this.getAutomaticPageField(),e},
            this.localDraftStatus.text(message || '');
        },


createDefaultField(e,t){return{label:this.humanizeName(e),description:"",type:t.conditionVariables.includes(e)?"boolean":"text"}},
        formatLocalDraftTime(timestamp) {
            if (!timestamp) {
                return '';
            }


humanizeName:e=>String(e||"").replace(/[_-]+/g," ").replace(/([a-z])([A-Z])/g,"$1 $2").replace(/\s+/g," ").trim().replace(/\b\w/g,e=>e.toUpperCase()),
            try {
                return new Date(timestamp).toLocaleTimeString([], {
                    hour: '2-digit',
                    minute: '2-digit',
                    second: '2-digit'
                });
            } catch (error) {
                return '';
            }
        },


prepareConfigForVariables(e,t){const a=(e=this.normalizeConfig(e)).fields||{},i=new Set(Array.isArray(this.state.deletedVariables)?this.state.deletedVariables.map(e=>String(e).toUpperCase()):[]),r={};r.PAGE=this.getAutomaticPageField(),t.variables.forEach(e=>{"PAGE"===e||i.has(e)||(r[e]=a[e]||this.createDefaultField(e,t))}),Object.keys(a).forEach(e=>{const t=e.toUpperCase();"PAGE"===t||r[t]||i.has(t)||(r[t]=a[e])}),e.fields=r;return e},
        updateLocalDraftIndicator() {
            if (!this.localDraftStatus) {
                return;
            }


validateConfig(e,t){const a=[],i=[];if(!e||"object"!=typeof e||Array.isArray(e))return{valid:!1,errors:["Config must be an object."],warnings:[]};if(!e.fields||"object"!=typeof e.fields||Array.isArray(e.fields))return{valid:!1,errors:['"fields" must be an object.'],warnings:i};const r=Object.keys(e.fields).map(e=>e.toUpperCase());t.variables.forEach(e=>{r.includes(e)||a.push('Variable "'+e+'" is not configured.')}),r.forEach(e=>{t.variables.includes(e)||i.push('Field "'+e+'" is not used by the preload.')}),r.includes("PAGE")||a.push("PAGE must always be configured."),Object.entries(e.fields).forEach(([e,t])=>{if(!t||"object"!=typeof t||Array.isArray(t))return void a.push('Field "'+e+'" must be an object.');t.type||i.push('Field "'+e+'" has no type.'),"select"!==t.type&&"multiselect"!==t.type||t.source||Array.isArray(t.options)||i.push('Field "'+e+'" is a '+t.type+" without a source or manual options.");if("file"===t.type){t.filename||a.push('File field "'+e+'" must define a filename template.');t.filetext===undefined&&i.push('File field "'+e+'" has no filetext; the uploaded File page will have an empty initial text.');t.accept!==undefined&&!Array.isArray(t.accept)&&a.push('File field "'+e+'" must have "accept" as an array.');t.accept&&Array.isArray(t.accept)&&t.accept.some(e=>!String(e).trim())&&a.push('File field "'+e+'" contains an empty accepted MIME type.')}}),{valid:!a.length,errors:a,warnings:i}},
            if (this.state.localDraftSavedAt) {
                const time = this.formatLocalDraftTime(this.state.localDraftSavedAt);


transformationObject(e,t,a,i,r){if(!e)return null;switch(e){case"remove-prefix":return t?{transform:e,prefix:t}:null;case"remove-suffix":return a?{transform:e,suffix:a}:null;case"replace":return""===i?null:{transform:e,from:i,to:r||""};default:return null}},
                this.updateLocalDraftStatus(
                    time ? 'Saved locally at ' + time : 'Saved locally',
                    'saved'
                );


createSection(e,a){const i=t("<section>").addClass("preload-editor-section");return a&&i.addClass(a),t("<h2>").addClass("preload-editor-section-title").text(e).appendTo(i),i},
                return;
            }


createButton(e,a,i){const r=t("<button>").attr("type","button").addClass("preload-editor-button").text(e);return a&&r.addClass(a),i&&r.on("click.preloadEditor",i),r},
            this.updateLocalDraftStatus('No local draft', 'empty');
        },


fieldRow(e,a,i){const r=t("<div>").addClass("preload-editor-field");return t("<label>").addClass("preload-editor-label").text(e).appendTo(r),i&&t("<div>").addClass("preload-editor-help").text(i).appendTo(r),r.append(a)},
        saveDraft() {
            if (!this.state.dirty) {
                return false;
            }


textInput:(e,a)=>t("<input>").attr({type:"text",value:null==e?"":e,placeholder:a||""}).addClass("preload-editor-input"),
            const snapshot = this.getDraftSnapshot();
            const id = this.getDraftId(snapshot.preloadTitle);
            const storage = this.getDraftStorage();


textarea:e=>t("<textarea>").val(null==e?"":e).addClass("preload-editor-textarea"),
            storage.drafts[id] = snapshot;
            storage.active = id;


checkbox:e=>t("<input>").attr("type","checkbox").prop("checked",!!e).addClass("preload-editor-checkbox"),
            const saved = this.setDraftStorage(storage);


select(e,a){const i=t("<select>").addClass("preload-editor-select");return e.forEach(e=>{t("<option>").attr("value",e.value).text(e.label).prop("selected",String(e.value)===String(void 0===a?"":a)).appendTo(i)}),i},
            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');
            }


createConfirmDialog(e,a,i,r){return new Promise(o=>{const s=t("<div>").addClass("preload-editor-prompt-overlay"),n=t("<div>").addClass("preload-editor-prompt");t("<h2>").text(e).appendTo(n);String(a||"").split("\n").forEach(e=>{""!==e?t("<p>").text(e).appendTo(n):t("<div>").addClass("preload-editor-prompt-spacer").appendTo(n)});const d=t("<div>").addClass("preload-editor-prompt-buttons"),l=this.createButton(r||"Cancel","",()=>{s.remove(),o(!1)}),c=this.createButton(i||"Confirm","preload-editor-button-primary",()=>{s.remove(),o(!0)});d.append(l,c),n.append(d),s.append(n),t(document.body).append(s),s.on("keydown.preloadEditor",e=>{"Escape"===e.key&&l.trigger("click")}),s.attr("tabindex","-1"),s.trigger("focus"),s.on("click.preloadEditor",e=>{e.target===s.get(0)&&l.trigger("click")})})},
            return saved;
        },


createPromptDialog(e,a){return new Promise(i=>{const r=t("<div>").addClass("preload-editor-prompt-overlay"),o=t("<div>").addClass("preload-editor-prompt");t("<h2>").text(e).appendTo(o),t("<p>").text(a).appendTo(o);const s=this.textInput(),n=t("<div>").addClass("preload-editor-prompt-buttons"),d=this.createButton("Cancel","",()=>{r.remove(),i(null)}),l=this.createButton("Load","preload-editor-button-primary",()=>{const e=String(s.val()||"").trim();e&&(r.remove(),i(e))});n.append(d,l),o.append(s,n),r.append(o),t(document.body).append(r),s.trigger("focus"),s.on("keydown.preloadEditor",e=>{"Enter"===e.key&&l.trigger("click"),"Escape"===e.key&&d.trigger("click")})})},
        clearDraft(title) {
            const storage = this.getDraftStorage();
            const id = this.getDraftId(title);


createConfigReviewDialog(){return new Promise(e=>{const a=t("<div>").addClass("preload-editor-prompt-overlay preload-editor-code-review-overlay"),i=t("<div>").addClass("preload-editor-prompt preload-editor-code-review");t("<h2>").text("Review config.json").appendTo(i),t("<p>").text("This is the config.json that will be saved for the current preload.").appendTo(i);const r=t("<pre>").addClass("preload-editor-config-review").text(this.getConfigJSON()),o=t("<div>").addClass("preload-editor-config-review-wrap").append(r);i.append(o);const s=t("<div>").addClass("preload-editor-prompt-buttons"),n=this.createButton("Close","preload-editor-button-primary",()=>{a.remove(),e()});s.append(n),i.append(s),a.append(i),t(document.body).append(a),a.attr("tabindex","-1"),a.trigger("focus"),a.on("keydown.preloadEditor",e=>{"Escape"===e.key&&n.trigger("click")}),a.on("click.preloadEditor",e=>{e.target===a.get(0)&&n.trigger("click")})})},
            if (storage.drafts && storage.drafts[id]) {
                delete storage.drafts[id];
            }


async confirmSave(e,t){const a=t.replace(/\/+$/,"")+"/config.json";let i;i="preload"===e?"Save the preload page only?":"config"===e?"Save the config.json page only?":"Save both the preload and config.json?";let r=[];"preload"!==e&&"both"!==e||r.push("Preload: "+t),"config"!==e&&"both"!==e||(r.push("Config: "+a),r.push("Config content model: JSON"));return this.createConfirmDialog("Confirm save",i+"\n\n"+r.join("\n")+"\n\nThis will write the changes to the wiki.","Save","Cancel")},
            if (storage.active === id) {
                storage.active = null;
            }


destroyEditor(){if(!this.editor)return this.editorView=null,void(this.editorTools=null);const{view:e,inputHandler:t}=this.editor;e&&t&&e.dom.removeEventListener("input",t),e&&e.destroy(),this.editor=null,this.editorView=null,this.editorTools=null},
            this.setDraftStorage(storage);


getEditorView(){return this.editorView},
            this.state.localDraftSavedAt = null;
            this.state.localDraftAvailable = false;


getEditorContent(){return this.editorView&&this.editorView.state?this.editorView.state.doc.toString():this.state.preloadContent||""},
            this.updateLocalDraftIndicator();
        },


setEditorContent(e){if(e=String(e||""),!this.editorView)return void(this.state.preloadContent=e);this.editorView.state.doc.toString()!==e&&this.editorView.dispatch({changes:{from:0,to:this.editorView.state.doc.length,insert:e}})},
        getMostRecentDraft() {
            const storage = this.getDraftStorage();


getEditorSelection(){if(!this.editorView||!this.editorView.state)return null;const e=this.editorView.state.selection.main;return{from:e.from,to:e.to,text:this.editorView.state.doc.sliceString(e.from,e.to)}},
            if (!storage.drafts || !Object.keys(storage.drafts).length) {
                return null;
            }


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


insertEditorText(e,t,a,i){if(!this.editorView)return;const r=this.editorView,o=r.state.selection.main,s=void 0===t?o.from:t,n=void 0===a?o.to:a,d=void 0===i?s+e.length:i;r.dispatch({changes:{from:s,to:n,insert:e},selection:{anchor:d}}),r.focus()},
            let latestId = null;
            let latestDraft = null;


wrapSelectionWithVariable(){const e=this.getEditorSelection();if(!e||e.from===e.to)return void this.focusEditor();const t="%%"+e.text+"%%";this.insertEditorText(t,e.from,e.to,e.from+t.length)},
            Object.entries(storage.drafts).forEach(([id, draft]) => {
                if (!draft || typeof draft !== 'object') {
                    return;
                }


insertVariable(e){if(!(e=String(e||"").trim().toUpperCase()))return;const t=this.getEditorSelection();t&&this.insertEditorText("%%"+e+"%%",t.from,t.to)},
                if (
                    !latestDraft ||
                    (Number(draft.savedAt) || 0) > (Number(latestDraft.savedAt) || 0)
                ) {
                    latestId = id;
                    latestDraft = draft;
                }
            });


insertCondition(e,t){e=String(e||"").toLowerCase(),t=String(t||"").trim().toUpperCase();const a=this.getEditorSelection();if(!a)return;let i,r;switch(e){case"if":{if(!t)return;const e="<!-- @if "+t+" -->",o="<!-- @endif -->";a.from!==a.to?(i=e+"\n"+a.text+"\n"+o,r=i.length):(i=e+"\n\n"+o,r=e.length+1);break}case"elseif":i="<!-- @elseif -->",r=i.length;break;case"else":i="<!-- @else -->",r=i.length;break;case"endif":i="<!-- @endif -->",r=i.length;break;default:return}this.insertEditorText(i,a.from,a.to,a.from+r)},
            if (!latestDraft) {
                return null;
            }


getToolVariables(){const e=new Set(["PAGE"]);return this.analysis&&this.analysis.variables&&this.analysis.variables.forEach(t=>e.add(String(t).toUpperCase())),Array.from(e)},
            return {
                id: latestId,
                draft: latestDraft
            };
        },


createToolSelect(e,a){const i=t("<select>").addClass("preload-editor-tool-select");return a&&i.addClass(a),e.forEach(e=>{t("<option>").attr("value",e.value).text(e.label).appendTo(i)}),i},
        async restoreDraft() {
            const result = this.getMostRecentDraft();


updateEditorToolVariables(){if(!this.editorTools)return;const e=this.getToolVariables(),a=this.editorTools.variableSelect.val(),i=this.editorTools.conditionVariableSelect.val(),r=(a,i)=>{a.empty(),e.forEach(e=>t("<option>").attr("value",e).text(e).appendTo(a)),i&&e.includes(i)?a.val(i):e.length&&a.val(e[0])};r(this.editorTools.variableSelect,a),r(this.editorTools.conditionVariableSelect,i)},
            if (!result || !result.draft) {
                return false;
            }


updateConditionVariableVisibility(){if(!this.editorTools)return;const e=this.editorTools.conditionTypeSelect.val();this.editorTools.conditionVariableWrap.toggle("if"===e)},
            const draft = result.draft;
            const title = String(draft.preloadTitle || '').trim();
            const displayTitle = title || 'an untitled preload';


renderEditorTools(){const e=t("<div>").addClass("preload-editor-tools"),a=t("<div>").addClass("preload-editor-tool-group preload-editor-tool-group-wrap");a.append(this.createButton("Wrap selection","preload-editor-tool-button",()=>this.wrapSelectionWithVariable()));const i=t("<div>").addClass("preload-editor-tool-group preload-editor-tool-group-variable");t("<span>").addClass("preload-editor-tool-label").text("Insert existing variable").appendTo(i);const r=this.createToolSelect([],"preload-editor-variable-select");i.append(r,this.createButton("Insert","preload-editor-tool-button",()=>this.insertVariable(r.val())));const o=t("<div>").addClass("preload-editor-tool-group preload-editor-tool-group-condition");t("<span>").addClass("preload-editor-tool-label").text("Insert condition").appendTo(o);const s=this.createToolSelect([{value:"if",label:"@if"},{value:"elseif",label:"@elseif"},{value:"else",label:"@else"},{value:"endif",label:"@endif"}],"preload-editor-condition-type-select"),n=this.createToolSelect([],"preload-editor-condition-variable-select"),d=t("<span>").addClass("preload-editor-condition-variable").append(n),l=this.createButton("Insert","preload-editor-tool-button",()=>this.insertCondition(s.val(),n.val()));return s.on("change.preloadEditor",()=>this.updateConditionVariableVisibility()),o.append(s,d,l),e.append(a,i,o),this.editorTools={root:e,variableSelect:r,conditionTypeSelect:s,conditionVariableSelect:n,conditionVariableWrap:d,insertCondition:l},this.updateEditorToolVariables(),this.updateConditionVariableVisibility(),e},
            const savedAt = draft.savedAt
                ? new Date(draft.savedAt).toLocaleString()
                : '';


updateEditorTools(){this.updateEditorToolVariables(),this.updateConditionVariableVisibility()},
            const message =
                'Preload Editor found unsaved local work for ' +
                displayTitle +
                (savedAt ? '\n\nLast saved locally: ' + savedAt : '') +
                '\n\nRestore it?';


getKnownVariables(){const e=new Set(["PAGE"]),t=this.state.config&&this.state.config.fields;return t&&"object"==typeof t&&Object.keys(t).forEach(t=>e.add(String(t).toUpperCase())),e},
            const restore = await this.createConfirmDialog(
                'Restore unsaved work',
                message,
                'Restore',
                'Discard'
            );


createSyntaxDecorations(e,t){const a=[],i=e.doc.toString(),r=this.getKnownVariables(),o=/%%([A-Za-z0-9_-]+)%%/g;let s;for(;null!==(s=o.exec(i));){const e=s[1].toUpperCase(),i=r.has(e);a.push(t.mark({class:["preload-editor-syntax-variable",i?"preload-editor-syntax-variable-known":"preload-editor-syntax-variable-unknown"].join(" ")}).range(s.index,s.index+s[0].length))}const n=/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;for(;null!==(s=n.exec(i));){a.push(t.mark({class:"preload-editor-syntax-condition"}).range(s.index,s.index+s[0].length));const e=s.index+s[0].indexOf("@");a.push(t.mark({class:"preload-editor-syntax-directive"}).range(e,e+s[1].length+1));const i=s[1].toLowerCase();if("if"!==i&&"elseif"!==i)continue;const o=s[2],n=s.index+s[0].indexOf(o),d=/[A-Za-z0-9_-]+/g;let l;for(;null!==(l=d.exec(o));){const e=l[0].toUpperCase();if(!r.has(e))continue;const i=n+l.index;a.push(t.mark({class:"preload-editor-syntax-condition-variable"}).range(i,i+l[0].length))}}return t.set(a,!0)},
            if (!restore) {
                this.clearDraft(title);
                return false;
            }


async installSyntaxHighlighting(t){await e.loader.using("ext.CodeMirror.v6.lib");const a=e.loader.require,{StateField:i,StateEffect:r,EditorView:o,Decoration:s}=a("ext.CodeMirror.v6.lib"),n=i.define({create:e=>this.createSyntaxDecorations(e,s),update:(e,t)=>t.docChanged?this.createSyntaxDecorations(t.state,s):e,provide:e=>o.decorations.from(e)});t.dispatch({effects:r.appendConfig.of(n)}),this.editor.syntaxField=n},
            let config = this.emptyConfig();


async mountCodeMirror(t){return new Promise((a,i)=>{let r=!1;const o=()=>{e.hook("dev.CCM.ready").remove(s),e.hook("dev.CCM.load").remove(n)},s=async e=>{if(r||!e||e.textarea!==t||!e.view)return;r=!0,o();const s=e.view,n=()=>{this.state.preloadContent=s.state.doc.toString(),this.markPreloadDirty(),this.reanalyze(),this.renderVariablesBySelector(),this.renderVariableConfigurationBySelector(),this.renderAnalysisBySelector(),this.updateEditorTools(),this.saveDraft()};s.dom.addEventListener("input",n),s.dom.classList.add("preload-editor-codemirror");const d=s.dom.closest(".ext-codemirror-wrapper");d&&d.classList.add("preload-editor-codemirror-wrapper"),this.editor={cm:e,view:s,textarea:t,inputHandler:n},this.editorView=s,this.state.preloadContent=s.state.doc.toString();try{await this.installSyntaxHighlighting(s),this.state.dirty&&this.saveDraft(),a(s)}catch(e){i(e)}},n=e=>{if(!r&&"function"==typeof e)try{e(t)}catch(e){r=!0,o(),i(e)}};e.hook("dev.CCM.ready").add(s),e.hook("dev.CCM.load").add(n)})},
            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
                    );
                }
            }


init(){const e=t("#mw-content-text");e.length&&(this.root=t("<div>").attr("id","preload-editor").addClass("preload-editor"),e.append(this.root),this.renderShell(),this.restoreDraft(),window.addEventListener("beforeunload",()=>{this.state.dirty&&this.saveDraft()}))},
            const hasDirtyFlags =
                typeof draft.dirtyPreload === 'boolean' ||
                typeof draft.dirtyConfig === 'boolean';


renderShell(){this.destroyEditor(),this.root.empty();const e=this.createSection("Preload"),a=this.textInput(this.state.preloadTitle,"Template:Example/Preload").attr("id","preload-editor-title-input");a.on("input.preloadEditor",()=>{this.state.preloadTitle=String(a.val()||"").trim(),this.state.dirty&&"new"===this.state.mode&&this.saveDraft()});const i=this.createButton("Load","preload-editor-button-primary",()=>this.loadExisting(a.val())),r=this.createButton("New","",()=>this.startNew()),o=this.createButton("Load Sample Page","",()=>this.loadSamplePage());e.append(t("<div>").addClass("preload-editor-toolbar").append(a,i,r,o)),this.root.append(e),this.workspace=t("<div>").addClass("preload-editor-workspace"),this.root.append(this.workspace);const s=t("<div>").addClass("preload-editor-footer");this.localDraftStatus=t("<div>").addClass("preload-editor-local-draft-status is-empty").text("No local draft"),this.status=t("<div>").addClass("preload-editor-status"),s.append(this.localDraftStatus,this.status),this.root.append(s),this.updateLocalDraftIndicator(),this.renderWelcome()},
            this.state = {
                preloadTitle: title,
                preloadSource: String(draft.preloadSource || ''),
                preloadContent: String(draft.preloadContent || ''),
                config,


renderWelcome(){this.workspace.empty();const e=this.createSection("Get started");t("<p>").text("Load an existing preload, create a new one, or start from a sample wiki page.").appendTo(e);const a=t("<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(e=>t("<li>").text(e).appendTo(a)),e.append(a),this.workspace.append(e)},
                variables: Array.isArray(draft.variables)
                    ? draft.variables.slice()
                    : [],


setStatus(e,t){this.status&&(this.status.removeClass("is-error","is-success","is-warning","is-loading"),t&&this.status.addClass("is-"+t),this.status.text(e||""))},
                conditions: Array.isArray(draft.conditions)
                    ? draft.conditions.slice()
                    : [],


async loadExisting(t){if(t=String(t||"").trim()){this.setStatus("Loading preload...","loading");try{const e=await this.getPage(t);if(!e){const e=await this.findSimilarPreloads(t);return this.renderMissingPage(t,e),void this.setStatus("Preload not found.","warning")}await this.loadDefinition(e.title,e.content),this.setStatus("Preload loaded.","success")}catch(e){console.error("Preload Editor:",e),this.setStatus(e.message||"Could not load preload.","error")}}else e.notify("Enter a preload page title first.",{type:"warn"})},
                selectedVariable: String(draft.selectedVariable || 'PAGE'),


async findSimilarPreloads(e){let t=e.replace(/\/Preload$/i,"").trim();t||(t=e);try{return(await this.searchPages(t)).filter(e=>/\/Preload$/i.test(e.title))}catch(e){return console.error("Preload Editor search:",e),[]}},
                deletedVariables: Array.isArray(draft.deletedVariables)
                    ? draft.deletedVariables
                        .map(name => String(name).toUpperCase())
                        .filter(name => name !== 'PAGE')
                    : [],


renderMissingPage(e,a){this.workspace.empty();const i=this.createSection("Preload not found");if(t("<p>").text('No preload exists at "'+e+'".').appendTo(i),a.length){t("<h3>").text("Similar preloads").appendTo(i);const e=t("<div>").addClass("preload-editor-suggestions");a.forEach(t=>e.append(this.createButton(t.title,"preload-editor-suggestion",()=>this.loadExisting(t.title)))),i.append(e)}i.append(this.createButton("Create this preload","preload-editor-button-primary",()=>this.startNew(e))),this.workspace.append(i)},
                mode: draft.mode || 'edit',


async startNew(e){e=String(e||"").trim(),this.state={preloadTitle:e,preloadSource:"",preloadContent:this.defaults.preload,config:this.emptyConfig(),variables:["PAGE"],conditions:[],selectedVariable:"PAGE",deletedVariables:[],mode:"new",dirty:!0,dirtyPreload:!0,dirtyConfig:!0,localDraftSavedAt:null,localDraftAvailable:!1},this.saveDraft(),this.renderEditor(),this.setStatus("Creating a new preload.","success")},
                dirty: hasDirtyFlags
                    ? !!(draft.dirtyPreload || draft.dirtyConfig)
                    : true,


async loadSamplePage(){const e=await this.createPromptDialog("Load Sample Page","Enter the title of a normal wiki page to use as the starting point.");if(e){this.setStatus("Loading sample page...","loading");try{const t=await this.getPage(e);if(!t)throw new Error("Sample page not found: "+e);const a=this.convertSampleToPreload(t.content);this.state={preloadTitle:"",preloadSource:"",preloadContent:a,config:this.emptyConfig(),variables:["PAGE"],conditions:[],selectedVariable:"PAGE",deletedVariables:[],mode:"new",dirty:!0,dirtyPreload:!0,dirtyConfig:!0,localDraftSavedAt:null,localDraftAvailable:!1},this.reanalyze(),this.saveDraft(),this.renderEditor(),this.setStatus("Sample page loaded.","success")}catch(e){console.error("Preload Editor sample:",e),this.setStatus(e.message||"Could not load sample page.","error")}}},
                dirtyPreload: hasDirtyFlags
                    ? !!draft.dirtyPreload
                    : true,


convertSampleToPreload(e){let t=String(e||"").trim();const a=t.match(/<includeonly>([\s\S]*?)<\/includeonly>/i);return a&&(t=a[1].trim()),t=t.replace(/<noinclude>[\s\S]*?<\/noinclude>/gi,"").trim(),"<includeonly>\n"+t+"\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n"},
                dirtyConfig: hasDirtyFlags
                    ? !!draft.dirtyConfig
                    : true,


async loadDefinition(e,t){const a=this.extractIncludeonly(t);let i=this.emptyConfig();const r=e.replace(/\/+$/,"")+"/config.json",o=await this.getPage(r);if(o)try{i=JSON.parse(o.content)}catch(e){throw new Error("The existing config.json contains invalid JSON: "+e.message)}const s=this.analyzePreload(a);this.state.deletedVariables=[],i=this.prepareConfigForVariables(i,s),this.state={preloadTitle:e,preloadSource:t,preloadContent:t,config:i,variables:s.variables,conditions:s.conditions,selectedVariable:s.variables.includes("PAGE")?"PAGE":s.variables[0]||"PAGE",deletedVariables:[],mode:"edit",dirty:!1,dirtyPreload:!1,dirtyConfig:!1,localDraftSavedAt:null,localDraftAvailable:!1},this.clearDraft(e),this.renderEditor()},
                localDraftSavedAt: Number(draft.savedAt) || null,
                localDraftAvailable: true
            };


renderEditor(){this.destroyEditor(),this.workspace.empty();const e=t("<div>").addClass("preload-editor-layout"),a=t("<div>").addClass("preload-editor-left"),i=t("<div>").addClass("preload-editor-right"),r=this.createSection("Preload","preload-editor-main-section"),o=t("<div>").addClass("preload-editor-meta");t("<strong>").text(this.state.preloadTitle||"New preload").appendTo(o),"new"===this.state.mode&&t("<span>").addClass("preload-editor-badge").text("NEW").appendTo(o),r.append(o),r.append(this.renderEditorTools());const s=this.textarea(this.state.preloadContent).attr("id","preload-editor-content").addClass("preload-editor-content"),n=t("<div>").addClass("preload-editor-codemirror-mount").append(s);r.append(n),a.append(r);const d=this.createSection("Analysis"),l=t("<div>").addClass("preload-editor-analysis");d.append(l);const c=this.createSection("Variables"),p=this.createButton("Detect variables","preload-editor-button-primary preload-editor-detect-variables",()=>this.detectVariables()),h=t("<div>").addClass("preload-editor-help preload-editor-detect-variables-help").text("Refresh the variable list after editing the preload.");c.append(t("<div>").addClass("preload-editor-variable-detect").append(p,h));const f=t("<div>").addClass("preload-editor-variables");c.append(f);const u=this.createSection("Variable configuration"),g=this.createButton("Import definitions","preload-editor-button-primary preload-editor-import-definitions",()=>this.importVariableDefinitions()),y=t("<div>").addClass("preload-editor-help preload-editor-import-definitions-help").text("Import variable definitions from another preload config.json. Existing definitions can be replaced."),v=t("<div>").addClass("preload-editor-import-definition-controls").append(g,y);u.append(v);const m=t("<div>").addClass("preload-editor-variable-configuration");u.append(m);const b=this.createSection("Configuration"),C=t("<div>").addClass("preload-editor-help").text("Review the exact config.json that will be uploaded to the wiki."),w=this.createButton("Review config.json","",()=>this.createConfigReviewDialog());b.append(C,w),i.append(d,c,u,b),e.append(a,i);const S=t("<div>").addClass("preload-editor-save-bar");S.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(S),this.workspace.append(e),this.reanalyze(),this.renderVariables(f),this.renderAnalysis(l),this.renderVariableConfiguration(m),this.updateEditorTools(),this.updateLocalDraftIndicator(),this.mountCodeMirror(s.get(0)).catch(e=>{console.error("Preload Editor CodeMirror:",e),this.setStatus(e.message||"Could not initialize CodeMirror.","error")})},
            this.reanalyze();
            this.renderEditor();
            this.updateLocalDraftIndicator();


detectVariables(){const e=this.getEditorContent();this.state.preloadContent=e;const t=this.extractIncludeonly(e),a=this.analyzePreload(t);if(Array.isArray(this.state.deletedVariables)){const e=new Set(a.variables.map(e=>String(e).toUpperCase()));this.state.deletedVariables=this.state.deletedVariables.filter(t=>!e.has(String(t).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")},
            this.setStatus(
                'Restored unsaved local work from this browser.',
                'warning'
            );


reanalyze(){const e=this.getEditorContent(),t=this.extractIncludeonly(e),a=this.analyzePreload(t);this.state.preloadContent=e,this.state.variables=a.variables,this.state.conditions=a.conditions,this.state.config=this.prepareConfigForVariables(this.state.config||this.emptyConfig(),a),this.analysis=a;const i=["PAGE"].concat(a.variables.filter(e=>"PAGE"!==e));i.includes(this.state.selectedVariable)||(this.state.selectedVariable=i[0]||"PAGE")},
            mw.notify('Unsaved Preload Editor work restored.', {
                type: 'success'
            });


renderAnalysis(e){e.empty();const a=this.analysis||this.analyzePreload(this.extractIncludeonly(this.getEditorContent())),i=a.variables.length,r=a.conditions.length,o=Object.keys(this.state.config&&this.state.config.fields?this.state.config.fields:{}).length,s=t("<div>").addClass("preload-editor-analysis-summary"),n=(e,a)=>t("<div>").addClass("preload-editor-analysis-metric").append(t("<strong>").addClass("preload-editor-analysis-value").text(e),t("<span>").addClass("preload-editor-analysis-label").text(a));s.append(n(i,1===i?"variable":"variables"),n(r,1===r?"condition marker":"condition markers"),n(o,1===o?"configured field":"configured fields")),e.append(s),a.conditionVariables.length&&e.append(t("<div>").addClass("preload-editor-analysis-note").text("Condition variables: "+a.conditionVariables.join(", ")));const d=this.validateConfig(this.state.config,a);if(d.errors.length){const a=t("<ul>").addClass("preload-editor-validation-errors");d.errors.forEach(e=>t("<li>").text(e).appendTo(a)),e.append(a)}if(d.warnings.length){const a=t("<ul>").addClass("preload-editor-validation-warnings");d.warnings.forEach(e=>t("<li>").text(e).appendTo(a)),e.append(a)}d.errors.length||d.warnings.length||e.append(t("<div>").addClass("preload-editor-validation-success").text("Preload and configuration are in sync."))},
            return true;
        },


renderAnalysisBySelector(){const e=this.root&&this.root.find(".preload-editor-analysis");e&&e.length&&this.renderAnalysis(e)},
        // =========================================================
        // API
        // =========================================================


renderVariables(e){e.empty();const a=this.analysis||this.analyzePreload(this.extractIncludeonly(this.getEditorContent())),i=a.variables.slice(),r=["PAGE"];i.forEach(e=>{"PAGE"===e||r.includes(e)||r.push(e)}),(this.state.config&&this.state.config.fields?Object.keys(this.state.config.fields):[]).forEach(e=>{const t=e.toUpperCase();"PAGE"===t||r.includes(t)||r.push(t)}),(Array.isArray(this.state.deletedVariables)?this.state.deletedVariables:[]).forEach(e=>{const t=String(e).toUpperCase();"PAGE"===t||r.includes(t)||r.push(t)});const o=t("<div>").addClass("preload-editor-variable-list");r.forEach(e=>{const i=e===this.state.selectedVariable,r=a.variables.includes(e),s=!!(this.state.config&&this.state.config.fields&&this.state.config.fields[e]),n=t("<button>").attr({type:"button","data-variable":e}).addClass("preload-editor-variable-item").toggleClass("is-selected",i).on("click.preloadEditor",()=>this.selectVariable(e));t("<span>").addClass("preload-editor-variable-name").text(e).appendTo(n),"PAGE"===e?n.append(t("<span>").addClass("preload-editor-badge").text("AUTOMATIC")):s?r||n.append(t("<span>").addClass("preload-editor-badge is-warning").text("UNUSED")):n.append(t("<span>").addClass("preload-editor-badge is-warning").text(r?"NOT CONFIGURED":"DELETED")),o.append(n)}),e.append(o),r.length||e.append(t("<div>").addClass("preload-editor-empty").text("No variables found in the preload."))},
        async getPage(title) {
            const response = await new mw.Api().get({
                action: 'query',
                prop: 'info|revisions',
                titles: title,
                rvprop: 'content',
                rvslots: 'main',
                formatversion: 2
            });


renderVariablesBySelector(){const e=this.root&&this.root.find(".preload-editor-variables");e&&e.length&&this.renderVariables(e)},
            const page =
                response &&
                response.query &&
                response.query.pages &&
                response.query.pages[0];


selectVariable(e){if(!(e=String(e||"").trim().toUpperCase()))return;const t=["PAGE"].concat((this.analysis&&this.analysis.variables?this.analysis.variables:[]).filter(e=>"PAGE"!==e));(this.state.config&&this.state.config.fields?Object.keys(this.state.config.fields).map(e=>e.toUpperCase()):[]).forEach(e=>{t.includes(e)||"PAGE"===e||t.push(e)}),t.includes(e)&&(this.state.selectedVariable=e,this.renderVariablesBySelector(),this.renderVariableConfigurationBySelector())},
            if (!page || page.missing) {
                return null;
            }


renderVariableConfigurationBySelector(){const e=this.root&&this.root.find(".preload-editor-variable-configuration");e&&e.length&&this.renderVariableConfiguration(e)},
            const content =
                page.revisions &&
                page.revisions[0] &&
                page.revisions[0].slots &&
                page.revisions[0].slots.main &&
                page.revisions[0].slots.main.content;


renderVariableConfiguration(e){e.empty();const a=this.analysis||this.analyzePreload(this.extractIncludeonly(this.getEditorContent())),i=this.state.selectedVariable||"PAGE",r=(this.state.config&&this.state.config.fields?this.state.config.fields:{})[i];if("PAGE"!==i){if(!r){const a=t("<div>").addClass("preload-editor-variable preload-editor-variable-missing");return t("<div>").addClass("preload-editor-variable-header").append(t("<h3>").text(i),t("<span>").addClass("preload-editor-badge is-warning").text("NOT CONFIGURED")).appendTo(a),t("<p>").addClass("preload-editor-analysis-note").text("This variable is currently not present in config.json.").appendTo(a),void e.append(a)}e.append(this.createVariableCard(i,r,a))}else e.append(this.createAutomaticPageCard(r||this.getAutomaticPageField()))},
            return {
                title: page.title || title,
                content: typeof content === 'string' ? content : '',
                contentmodel: page.contentmodel || 'wikitext'
            };
        },


createAutomaticPageCard(e){const a=t("<div>").addClass("preload-editor-variable preload-editor-variable-automatic").attr("data-variable","PAGE"),i=t("<div>").addClass("preload-editor-variable-header");i.append(t("<h3>").text("PAGE"),t("<span>").addClass("preload-editor-badge").text("AUTOMATIC")),a.append(i),t("<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(a);const r=this.textInput(e.label||"Page Name").prop("disabled",!0),o=this.textarea(e.description||"Name of the page.").addClass("preload-editor-small-textarea").prop("disabled",!0),s=this.textInput(e.type||"page").prop("disabled",!0),n=this.checkbox(!1!==e.required).prop("disabled",!0),d=this.textInput(e.namespace||"Main").prop("disabled",!0),l=this.checkbox(!0===e["allow-new"]).prop("disabled",!0);return a.append(this.fieldRow("Label",r),this.fieldRow("Description",o),this.fieldRow("Type",s),this.fieldRow("Required",n),this.fieldRow("Namespace",d),this.fieldRow("Allow new page",l)),a},
        async pageExists(title) {
            return !!await this.getPage(title);
        },


createVariableCard(e,a,i){const r=t("<div>").addClass("preload-editor-variable").attr("data-variable",e),o=t("<div>").addClass("preload-editor-variable-header");o.append(t("<h3>").text(e)),i.variables.includes(e)||o.append(t("<span>").addClass("preload-editor-badge is-warning").text("UNUSED")),r.append(o);const s=this.textInput(a.label||"");s.on("input.preloadEditor",()=>{a.label=s.val(),this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Label",s));const n=this.textarea(a.description||"").addClass("preload-editor-small-textarea");n.on("input.preloadEditor",()=>{a.description=n.val(),this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Description",n));const d=this.select([{value:"text",label:"Text"},{value:"textarea",label:"Textarea"},{value:"number",label:"Number"},{value:"boolean",label:"Boolean"},{value:"select",label:"Select"},{value:"multiselect",label:"Multi-select"},{value:"page",label:"Page"},{value:"user",label:"User"},{value:"date",label:"Date"},{value:"file",label:"File"}],a.type||"text");d.on("change.preloadEditor",()=>{a.type=d.val(),this.markConfigDirty(),this.renderVariableConfigurationBySelector(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Type",d));const l=this.checkbox(!0===a.required);l.on("change.preloadEditor",()=>{a.required=l.prop("checked"),this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Required",l));const c=this.textInput(a.placeholder||"");c.on("input.preloadEditor",()=>{const e=c.val();e?a.placeholder=e:delete a.placeholder,this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Placeholder",c));const p=this.textInput(void 0===a.default?"":a.default);if(p.on("input.preloadEditor",()=>{const e=p.val();e?a.default=e:delete a.default,this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Default",p,"For text-like fields this is used as a placeholder. For select fields it is the empty/default option.")),"textarea"===a.type){const e=this.textInput(a.maxRows||"").attr("type","number");e.on("input.preloadEditor",()=>{const t=parseInt(e.val(),10);Number.isFinite(t)&&t>0?a.maxRows=t:delete a.maxRows,this.markConfigDirty(),this.renderConfigReviewAvailability()}),r.append(this.fieldRow("Maximum rows",e))}"select"!==a.type&&"multiselect"!==a.type||this.renderSelectConfiguration(r,a),"file"===a.type&&this.renderFileConfiguration(r,a);const h=this.createButton("Delete variable","preload-editor-button-danger preload-editor-delete-variable",async()=>{await this.createConfirmDialog("Delete variable",'Delete "'+e+'" from the configuration?\n\nThe variable will remain in the preload, but its config.json field will be removed.',"Delete variable","Cancel")&&this.deleteVariable(e)});return r.append(t("<div>").addClass("preload-editor-variable-delete").append(h)),r},
        async searchPages(query) {
            const response = await new mw.Api().get({
                action: 'query',
                list: 'search',
                srsearch: query,
                srlimit: 10,
                srnamespace: 10,
                formatversion: 2
            });


renderFileConfiguration(e,a){const i=t("<div>").addClass("preload-editor-file-configuration"),r=t("<h4>").text("File upload settings");i.append(r);const o=this.textInput(a.filename||"%%PAGE%%.%%EXT%%"),s=this.textarea(a.filetext||"").addClass("preload-editor-small-textarea"),n=this.textarea(Array.isArray(a.accept)?a.accept.join("\n"):"").addClass("preload-editor-small-textarea"),d=this.textInput(a.comment||"");o.on("input.preloadEditor",()=>{const e=o.val();e?a.filename=e:delete a.filename,this.markConfigDirty(),this.renderConfigReviewAvailability()}),s.on("input.preloadEditor",()=>{const e=s.val();e?a.filetext=e:delete a.filetext,this.markConfigDirty(),this.renderConfigReviewAvailability()}),n.on("input.preloadEditor",()=>{const e=n.val().split("\n").map(e=>e.trim()).filter(Boolean);e.length?a.accept=e:delete a.accept,this.markConfigDirty(),this.renderConfigReviewAvailability()}),d.on("input.preloadEditor",()=>{const e=d.val();e?a.comment=e:delete a.comment,this.markConfigDirty(),this.renderConfigReviewAvailability()}),i.append(this.fieldRow("Filename template",o,"The generated filename. Supports normal variables plus %%EXT%%, %%ORIGINAL%% and %%ORIGINAL_NAME%%.")),i.append(this.fieldRow("File page text",s,"Initial wikitext for the File: page. Normal %%VARIABLE%% substitutions are supported.")),i.append(this.fieldRow("Accepted MIME types",n,"One MIME type per line, for example image/png and image/gif.")),i.append(this.fieldRow("Upload summary",d,"Optional edit summary for the file upload.")),e.append(i)},
            return (
                response &&
                response.query &&
                response.query.search
            ) || [];
        },


async deleteVariable(e){(e=String(e||"").trim().toUpperCase())&&"PAGE"!==e&&this.state.config&&this.state.config.fields&&(delete this.state.config.fields[e],Array.isArray(this.state.deletedVariables)||(this.state.deletedVariables=[]),this.state.deletedVariables.includes(e)||this.state.deletedVariables.push(e),this.state.selectedVariable=e,this.markConfigDirty(),this.renderVariablesBySelector(),this.renderVariableConfigurationBySelector(),this.renderAnalysisBySelector(),this.renderConfigReviewAvailability(),this.setStatus('Variable "'+e+'" removed from the configuration.',"warning"))},
        /*
        * 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
            };


renderSelectConfiguration(e,t){const a=this.textInput(t.source||"","category:Example");a.on("input.preloadEditor",()=>{const e=a.val();e?t.source=e:delete t.source,this.markConfigDirty(),this.renderConfigReviewAvailability()}),e.append(this.fieldRow("Source",a,"Currently supported source format: category:Category Name"));const i=this.textInput(t["source-prefix"]||"");i.on("input.preloadEditor",()=>{const e=i.val();e?t["source-prefix"]=e:delete t["source-prefix"],this.markConfigDirty(),this.renderConfigReviewAvailability()}),e.append(this.fieldRow("Source prefix",i,"Only source values beginning with this text are included."));const r=this.textInput(t.exclude&&t.exclude.category||"");r.on("input.preloadEditor",()=>{const e=r.val();e?t.exclude=Object.assign({},t.exclude||{},{category:e}):t.exclude&&(delete t.exclude.category,Object.keys(t.exclude).length||delete t.exclude),this.markConfigDirty(),this.renderConfigReviewAvailability()}),e.append(this.fieldRow("Exclude category",r)),e.append(this.createTransformControls(t,"value","Value transformation"),this.createTransformControls(t,"label","Label transformation"));const o=Array.isArray(t.options)?t.options.map(e=>"string"==typeof e?e:String(e.value||"")+" | "+String(e.label||e.value||"")).join("\n"):"",s=this.textarea(o).addClass("preload-editor-small-textarea");s.on("input.preloadEditor",()=>{const e=s.val().split("\n").map(e=>e.trim()).filter(Boolean);e.length?t.options=e.map(e=>{const t=e.split(/\s*\|\s*/);return 1===t.length?t[0]:{value:t[0],label:t.slice(1).join(" | ")}}):delete t.options,this.markConfigDirty(),this.renderConfigReviewAvailability()}),e.append(this.fieldRow("Manual options",s,"One per line. Optional label format: value | label. Leave empty when using a source."))},
            if (createOnly) {
                params.createonly = true;
            }


createTransformControls(e,a,i){const r=t("<div>").addClass("preload-editor-transform");t("<h4>").text(i).appendTo(r);const o=e[a]&&"object"==typeof e[a]?e[a]:{},s=this.select([{value:"",label:"No transformation"},{value:"remove-prefix",label:"Remove prefix"},{value:"remove-suffix",label:"Remove suffix"},{value:"replace",label:"Replace"}],o.transform||""),n=this.textInput(o.prefix||""),d=this.textInput(o.suffix||""),l=this.textInput(o.from||""),c=this.textInput(o.to||""),p=t("<div>").addClass("preload-editor-transform-controls"),h=this.fieldRow("Prefix",n),f=this.fieldRow("Suffix",d),u=this.fieldRow("From",l),g=this.fieldRow("To",c),y=()=>{const e=s.val();h.toggle("remove-prefix"===e),f.toggle("remove-suffix"===e),u.toggle("replace"===e),g.toggle("replace"===e)},v=()=>{const t=s.val();let i=null;"remove-prefix"===t&&n.val()?i={transform:t,prefix:n.val()}:"remove-suffix"===t&&d.val()?i={transform:t,suffix:d.val()}:"replace"===t&&l.val()&&(i={transform:t,from:l.val(),to:c.val()}),i?e[a]=i:delete e[a],this.markConfigDirty(),this.renderConfigReviewAvailability()};return s.on("change.preloadEditor",()=>{v(),y()}),[n,d,l,c].forEach(e=>e.on("input.preloadEditor",v)),p.append(this.fieldRow("Transformation",s),h,f,u,g),y(),r.append(p),r},
            if (contentModel) {
                params.contentmodel = contentModel;
            }


getCleanConfig(){const e=JSON.parse(JSON.stringify(this.state.config)),t=e.fields||{},a={};return a.PAGE=this.getAutomaticPageField(),Object.keys(t).filter(e=>"PAGE"!==e.toUpperCase()).forEach(e=>{a[e]=t[e]}),e.fields=a,e},
            return new mw.Api().postWithToken('csrf', params);
        },


getConfigJSON(){return JSON.stringify(this.getCleanConfig(),null,4)},
        /*
        * 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'
            });
        },


renderConfigPreview(e){e&&e.text(this.getConfigJSON())},
        /*
        * 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);


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


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


updateDirtyState(){this.state.dirty=!(!this.state.dirtyPreload&&!this.state.dirtyConfig)},
            return this.savePage(
                title,
                content,
                summary,
                false
            );
        },


markPreloadDirty(){this.state.dirtyPreload=!0,this.updateDirtyState(),this.saveDraft()},
        // =========================================================
        // Config import helpers
        // =========================================================


markConfigDirty(){this.state.dirtyConfig=!0,this.updateDirtyState(),this.saveDraft()},
        /*
        * 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(/\/+$/, '');


markDirty(){this.state.dirtyPreload=!0,this.state.dirtyConfig=!0,this.updateDirtyState(),this.saveDraft()},
            if (/\/config\.json$/i.test(title)) {
                return title;
            }


async savePreloadOnly(){let t=String(this.state.preloadTitle||"").trim();if(!t){const e=await this.createPromptDialog("Preload title","Enter the full title for the preload.");if(!e)return;t=e,this.state.preloadTitle=t}this.state.preloadContent=this.getEditorContent();const a=this.validateBeforeSave();if(a.errors.length)return this.showValidationErrors(a),void this.saveDraft();if(await this.confirmSave("preload",t)){this.setStatus("Saving preload...","loading");try{const a=await this.pageExists(t);await this.savePage(t,this.state.preloadContent,a?"Updated preload with Preload Editor":"Created preload with Preload Editor",!a),this.state.mode="edit",this.state.preloadTitle=t,this.state.dirtyPreload=!1,this.updateDirtyState(),this.state.dirty?this.saveDraft():this.clearDraft(t),this.setStatus(this.state.dirtyConfig?"Preload saved. Config still has unsaved changes.":"Preload saved successfully.","success"),e.notify(this.state.dirtyConfig?"Preload saved. Config still has unsaved changes.":"Preload saved.",{type:"success"})}catch(t){console.error("Preload Editor save:",t),this.saveDraft(),this.setStatus(t.message||"Could not save preload.","error"),e.notify(t.message||"Could not save preload.","error")}}},
            return title + '/config.json';
        },


async saveConfigOnly(){let t=String(this.state.preloadTitle||"").trim();if(!t){const e=await this.createPromptDialog("Preload title","Enter the full title for the preload.");if(!e)return;t=e,this.state.preloadTitle=t}this.state.preloadContent=this.getEditorContent(),this.reanalyze();const a=this.validateBeforeSave();if(a.errors.length)return this.showValidationErrors(a),void this.saveDraft();if(!await this.confirmSave("config",t))return;const i=t.replace(/\/+$/,"")+"/config.json";this.setStatus("Saving config...","loading");try{await this.saveConfigPage(i,this.getConfigJSON(),await this.pageExists(i)?"Updated preload config with Preload Editor":"Created preload config with Preload Editor"),this.state.mode="edit",this.state.preloadTitle=t,this.state.dirtyConfig=!1,this.updateDirtyState(),this.state.dirty?this.saveDraft():this.clearDraft(t),this.setStatus(this.state.dirtyPreload?"Config saved as JSON. Preload still has unsaved changes.":"Config saved as JSON successfully.","success"),e.notify(this.state.dirtyPreload?"Config saved as JSON. Preload still has unsaved changes.":"Config saved as JSON.",{type:"success"})}catch(t){console.error("Preload Editor config save:",t),this.saveDraft(),this.setStatus(t.message||"Could not save config.","error"),e.notify(t.message||"Could not save config.","error")}},
        /*
        * Return a safe clone of a config field.
async saveAll(){let t=String(this.state.preloadTitle||"").trim();if(!t){const e=await this.createPromptDialog("Preload title","Enter the full title for the preload.");if(!e)return;t=e,this.state.preloadTitle=t}this.state.preloadContent=this.getEditorContent(),this.reanalyze();const a=this.validateBeforeSave();if(a.errors.length)return this.showValidationErrors(a),void this.saveDraft();if(!await this.confirmSave("both",t))return;const i=t.replace(/\/+$/,"")+"/config.json";this.setStatus("Saving preload and config...","loading");try{const a=await this.pageExists(t);await this.savePage(t,this.state.preloadContent,a?"Updated preload with Preload Editor":"Created preload with Preload Editor",!a);const r=await this.pageExists(i);await this.saveConfigPage(i,this.getConfigJSON(),r?"Updated preload config with Preload Editor":"Created preload config with Preload Editor"),this.state.mode="edit",this.state.dirtyPreload=!1,this.state.dirtyConfig=!1,this.state.dirty=!1,this.state.preloadTitle=t,this.clearDraft(t),this.setStatus("Preload and JSON config saved successfully.","success"),e.notify("Preload and config saved. config.json is using the JSON content model.",{type:"success"})}catch(t){console.error("Preload Editor save:",t),this.saveDraft(),this.setStatus(t.message||"Could not save preload/config.","error"),e.notify(t.message||"Could not save preload/config.","error")}},
        *
        * 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
                );


validateBeforeSave(){const e=[],t=[],a=String(this.state.preloadTitle||"").trim();a||e.push("A preload title is required."),a&&!/\/Preload$/i.test(a)&&t.push('The title does not end with "/Preload".');const i=this.getEditorContent();this.state.preloadContent=i,i.trim()||e.push("The preload cannot be empty."),/<includeonly>/i.test(i)||t.push("The preload does not contain an <includeonly> section."),/<noinclude>/i.test(i)||t.push("The preload does not contain a <noinclude> section.");const r=this.analyzePreload(this.extractIncludeonly(i));this.state.config=this.prepareConfigForVariables(this.state.config||this.emptyConfig(),r);const o=this.validateConfig(this.state.config,r);e.push(...o.errors),t.push(...o.warnings);let s=0;return r.conditions.forEach(t=>{"if"===t.type?s++:"endif"===t.type?(s--,s<0&&(e.push("Found @endif without a matching @if."),s=0)):"elseif"!==t.type&&"else"!==t.type||0===s&&e.push("@"+t.type+" exists outside an @if block.")}),s>0&&e.push("One or more @if blocks are missing @endif."),{errors:e,warnings:t,valid:0===e.length}},
                return null;
            }
        },


showValidationErrors(t){this.renderAnalysisBySelector(),this.saveDraft(),this.setStatus(t.errors.join(" "),"error"),e.notify(t.errors.join("\n"),{type:"error"})}};e.PreloadEditor=a,t(function(){"Petit_Planet:Preload_Editor"===e.config.get("wgPageName")&&a.init()})}(mediaWiki,jQuery);
        /*
        * 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 === '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
                );
            }
 
            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.'
                )
            );
        },
 
        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:Preload_Editor'
        ) {
            return;
        }
 
        PreloadEditor.init();
    });
 
}(mediaWiki, jQuery));