MediaWiki:Gadget-PreloadEditor.js: Difference between revisions

ReisuDesign (talk | contribs)
No edit summary
ReisuDesign (talk | contribs)
No edit summary
 
(3 intermediate revisions by the same user not shown)
Line 3: Line 3:


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


         state: {
         state: {
Line 33: Line 33:
         editorTools: null,
         editorTools: null,


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


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


Line 47: Line 45:
         getDraftStorage() {
         getDraftStorage() {
             try {
             try {
                 const raw =
                 const raw = window.localStorage.getItem(this.draftStorageKey);
                    window.localStorage.getItem(
                        this.draftStorageKey
                    );


                 if (!raw) {
                 if (!raw) {
Line 59: Line 54:
                 }
                 }


                 const parsed =
                 const parsed = JSON.parse(raw);
                    JSON.parse(raw);


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


                 if (
                 if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) {
                    !parsed.drafts ||
                    typeof parsed.drafts !== 'object' ||
                    Array.isArray(parsed.drafts)
                ) {
                     parsed.drafts = {};
                     parsed.drafts = {};
                 }
                 }
Line 83: Line 69:
                 return parsed;
                 return parsed;
             } catch (error) {
             } catch (error) {
                 console.warn(
                 console.warn('Preload Editor: Could not read local drafts.', error);
                    'Preload Editor: Could not read local drafts.',
                    error
                );


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


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


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


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


Line 138: Line 103:


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


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


                 variables:
                 variables: Array.isArray(this.state.variables)
                    Array.isArray(
                     ? this.state.variables.slice()
                        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:
                 conditions: Array.isArray(this.state.conditions)
                     this.state.mode ||
                     ? this.state.conditions.slice()
                     'edit',
                     : [],


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


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


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


         updateLocalDraftStatus(
         updateLocalDraftStatus(message, type) {
            message,
            type
        ) {
             if (!this.localDraftStatus) {
             if (!this.localDraftStatus) {
                 return;
                 return;
Line 237: Line 153:


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


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


Line 253: Line 165:


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


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


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


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


Line 302: Line 199:
             }
             }


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


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


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


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


Line 345: Line 221:


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


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


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


             this.setDraftStorage(
             this.setDraftStorage(storage);
                storage
            );
 
            this.state.localDraftSavedAt =
                null;


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


             this.updateLocalDraftIndicator();
             this.updateLocalDraftIndicator();
Line 378: Line 241:


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


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


             if (
             if (storage.active && storage.drafts[storage.active]) {
                storage.active &&
                storage.drafts[
                    storage.active
                ]
            ) {
                 return {
                 return {
                     id: storage.active,
                     id: storage.active,
                     draft:
                     draft: storage.drafts[storage.active]
                        storage.drafts[
                            storage.active
                        ]
                 };
                 };
             }
             }
Line 408: Line 257:
             let latestDraft = null;
             let latestDraft = null;


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


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


             if (!latestDraft) {
             if (!latestDraft) {
Line 449: Line 282:


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


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


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


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


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


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


             if (!restore) {
             if (!restore) {
Line 502: Line 314:
             }
             }


             let config =
             let config = this.emptyConfig();
                this.emptyConfig();


             if (
             if (
Line 511: Line 322:
             ) {
             ) {
                 try {
                 try {
                     config =
                     config = this.normalizeConfig(
                        this.normalizeConfig(
                        JSON.parse(JSON.stringify(draft.config))
                            JSON.parse(
                    );
                                JSON.stringify(
                                    draft.config
                                )
                            )
                        );
                 } catch (error) {
                 } catch (error) {
                     console.warn(
                     console.warn(
Line 532: Line 338:


             this.state = {
             this.state = {
                 preloadTitle:
                 preloadTitle: title,
                     title,
                preloadSource: String(draft.preloadSource || ''),
                preloadContent: String(draft.preloadContent || ''),
                config,
 
                variables: Array.isArray(draft.variables)
                    ? draft.variables.slice()
                     : [],
 
                conditions: Array.isArray(draft.conditions)
                    ? draft.conditions.slice()
                    : [],
 
                selectedVariable: String(draft.selectedVariable || 'PAGE'),


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


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


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


                 variables:
                 dirtyPreload: hasDirtyFlags
                     Array.isArray(
                     ? !!draft.dirtyPreload
                        draft.variables
                     : true,
                     )
                        ? draft.variables.slice()
                        : [],


                 conditions:
                 dirtyConfig: hasDirtyFlags
                     Array.isArray(
                     ? !!draft.dirtyConfig
                        draft.conditions
                     : true,
                     )
                        ? draft.conditions.slice()
                        : [],


                selectedVariable:
                 localDraftSavedAt: Number(draft.savedAt) || null,
                    String(
                 localDraftAvailable: true
                        draft.selectedVariable ||
             };
                        'PAGE'
                    ),
 
                deletedVariables:
                    Array.isArray(
                        draft.deletedVariables
                    )
                        ? draft.deletedVariables
                            .map(
                                name =>
                                    String(
                                        name
                                    ).toUpperCase()
                            )
                            .filter(
                                name =>
                                    name !==
                                    'PAGE'
                            )
                        : [],
 
                mode:
                    draft.mode ||
                    'edit',
 
                dirty:
                    hasDirtyFlags
                        ? !!(
                            draft.dirtyPreload ||
                            draft.dirtyConfig
                        )
                        : true,
 
                dirtyPreload:
                    hasDirtyFlags
                        ? !!draft.dirtyPreload
                        : true,
 
                dirtyConfig:
                    hasDirtyFlags
                        ? !!draft.dirtyConfig
                        : true,
 
                 localDraftSavedAt:
                    Number(
                        draft.savedAt
                    ) || null,
 
                 localDraftAvailable:
                    true
             };


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


Line 626: Line 386:
             );
             );


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


             return true;
             return true;
Line 641: Line 398:


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


             const page =
             const page =
Line 669: Line 425:


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


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


             return (
             return (
Line 714: Line 460:
         * changing, use changeContentModel() first.
         * changing, use changeContentModel() first.
         */
         */
         async savePage(
         async savePage(title, content, summary, createOnly, contentModel) {
            title,
            content,
            summary,
            createOnly,
            contentModel
        ) {
             const params = {
             const params = {
                 action: 'edit',
                 action: 'edit',
                 title,
                 title,
                 text: content,
                 text: content,
                 summary:
                 summary: summary || 'Updated with Preload Editor',
                    summary ||
                    'Updated with Preload Editor',
                 formatversion: 2
                 formatversion: 2
             };
             };
Line 736: Line 474:


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


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


Line 754: Line 488:
         * The page must already exist.
         * The page must already exist.
         */
         */
         async changeContentModel(
         async changeContentModel(title, model, summary) {
            title,
             return new mw.Api().postWithToken('csrf', {
            model,
                 action: 'changecontentmodel',
            summary
                title,
        ) {
                model,
             return new mw.Api().postWithToken(
                reason: summary || 'Changed content model with Preload Editor'
                'csrf',
            });
                 {
                    action:
                        'changecontentmodel',
 
                    title,
 
                    model,
 
                    reason:
                        summary ||
                        'Changed content model with Preload Editor'
                }
            );
         },
         },


Line 787: Line 508:
         *  then save the JSON normally.
         *  then save the JSON normally.
         */
         */
         async saveConfigPage(
         async saveConfigPage(title, content, summary) {
            title,
             const page = await this.getPage(title);
            content,
 
            summary
        ) {
             const page =
                await this.getPage(title);
 
             if (!page) {
             if (!page) {
                 return this.savePage(
                 return this.savePage(
Line 805: Line 521:
             }
             }


             if (
             if (String(page.contentmodel || '').toLowerCase() !== 'json') {
                String(
                    page.contentmodel || ''
                ).toLowerCase() !== 'json'
            ) {
                 await this.changeContentModel(
                 await this.changeContentModel(
                     title,
                     title,
Line 841: Line 553:
         */
         */
         normalizeConfigTitle(title) {
         normalizeConfigTitle(title) {
             title =
             title = String(title || '').trim();
                String(title || '').trim();
             title = title.replace(/\/+$/, '');
 
             title =
                title.replace(
                    /\/+$/,
                    ''
                );


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


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


Line 870: Line 571:
         cloneConfigField(field) {
         cloneConfigField(field) {
             try {
             try {
                 return JSON.parse(
                 return JSON.parse(JSON.stringify(field));
                    JSON.stringify(field)
                );
             } catch (error) {
             } catch (error) {
                 console.warn(
                 console.warn(
Line 888: Line 587:
         */
         */
         getImportableFields(sourceConfig) {
         getImportableFields(sourceConfig) {
             const fields =
             const fields = sourceConfig && sourceConfig.fields;
                sourceConfig &&
                sourceConfig.fields;


             if (
             if (
Line 902: Line 599:
             const result = {};
             const result = {};


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


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


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


                    if (!clone) {
                const clone = this.cloneConfigField(field);
                        return;
                    }


                     result[upper] =
                if (!clone) {
                        clone;
                     return;
                 }
                 }
             );
 
                result[upper] = clone;
             });


             return result;
             return result;
Line 954: Line 635:
         *  [...] -> selected field names
         *  [...] -> selected field names
         */
         */
         createImportDefinitionsDialog(
         createImportDefinitionsDialog(sourceTitle, fields) {
            sourceTitle,
             return new Promise(resolve => {
            fields
                const names = Object.keys(fields).sort((a, b) => a.localeCompare(b));
        ) {
 
             return new Promise(
                const overlay = $('<div>')
                resolve => {
                    .addClass(
                    const names =
                        'preload-editor-prompt-overlay preload-editor-import-overlay'
                        Object.keys(
                    );
                            fields
 
                        ).sort(
                const box = $('<div>')
                            (a, b) =>
                    .addClass(
                                a.localeCompare(
                         'preload-editor-prompt preload-editor-import-dialog'
                                    b
                    );
                                )
                         );


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


                    const box =
                $('<p>')
                        $('<div>')
                    .text(
                            .addClass(
                        'Choose which variable definitions to import from "' +
                                'preload-editor-prompt preload-editor-import-dialog'
                        sourceTitle +
                            );
                        '". Existing local definitions can be replaced.'
                    )
                    .appendTo(box);


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


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


                        const emptyButtons =
                    const close = this.createButton(
                            $('<div>')
                        'Close',
                                .addClass(
                        'preload-editor-button-primary',
                                    'preload-editor-prompt-buttons'
                        () => {
                                );
                            overlay.remove();
                            resolve(null);
                        }
                    );


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


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


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


                        overlay.append(box);
                    return;
                }


                        $(document.body)
                const search = this.textInput('', 'Search variables...')
                            .append(overlay);
                    .addClass('preload-editor-import-search');


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


                        overlay.trigger('focus');
                box.append(searchWrap);


                         return;
                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);


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


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


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


                    const controls =
                const localFields =
                         $('<div>')
                    this.state.config && this.state.config.fields
                            .addClass(
                         ? this.state.config.fields
                                'preload-editor-import-controls'
                        : {};
                            );


                    const selectAll =
                names.forEach(name => {
                        this.createButton(
                    const existing = !!localFields[name];
                            'Select all',
                    const field = fields[name];
                            '',
                            () => {
                                list
                                    .find(
                                        'input[type="checkbox"]:visible'
                                    )
                                    .prop(
                                        'checked',
                                        true
                                    );


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


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


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


                     controls.append(
                     const checkbox = this.checkbox(false)
                         selectAll,
                         .attr('data-import-variable', name)
                         deselectAll
                         .addClass('preload-editor-import-checkbox');
                     );
 
                     const content = $('<span>')
                        .addClass('preload-editor-import-content');


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


                     const list =
                     $('<strong>')
                        $('<div>')
                        .text(name)
                            .addClass(
                        .appendTo(header);
                                'preload-editor-import-list'
                            );


                     const localFields =
                     $('<span>')
                         this.state.config &&
                         .addClass('preload-editor-import-type')
                         this.state.config.fields
                         .text(type)
                            ? this.state.config.fields
                        .appendTo(header);
                            : {};


                     names.forEach(
                     const meta = $('<span>')
                         name => {
                         .addClass('preload-editor-import-meta');
                            const existing =
                                !!localFields[name];


                            const field =
                    $('<span>')
                                fields[name];
                        .text(label)
                        .appendTo(meta);


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


                            const label =
                    content.append(header, meta, status);
                                field &&
                    row.append(checkbox, content);
                                field.label
                    list.append(row);
                                    ? String(
                });
                                        field.label
                                    )
                                    : this.humanizeName(
                                        name
                                    );


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


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


                            const content =
                const updateSummary = () => {
                                $('<span>')
                    const selected = list.find(
                                    .addClass(
                        'input[type="checkbox"]:checked'
                                        'preload-editor-import-content'
                    ).length;
                                    );


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


                             $('<strong>')
                             return !!localFields[name];
                                .text(name)
                        }).length;
                                .appendTo(header);


                            $('<span>')
                    let text =
                                .addClass(
                        selected +
                                    'preload-editor-import-type'
                        ' ' +
                                )
                        (selected === 1 ? 'variable' : 'variables') +
                                .text(type)
                        ' selected.';
                                .appendTo(header);


                             const meta =
                    if (existingSelected) {
                                $('<span>')
                        text +=
                                    .addClass(
                             ' ' +
                                        'preload-editor-import-meta'
                            existingSelected +
                                    );
                            ' existing ' +
                            (existingSelected === 1
                                ? 'definition'
                                : 'definitions') +
                            ' will be replaced.';
                    }


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


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


                            content.append(
                const filterRows = () => {
                                header,
                    const query = String(search.val() || '')
                                meta,
                        .trim()
                                status
                        .toLowerCase();
                            );


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


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


                    box.append(
                search.on(
                        list
                    'input.preloadEditor',
                     );
                     filterRows
                );


                     const summary =
                list.on(
                        $('<div>')
                     'change.preloadEditor',
                            .addClass(
                    'input[type="checkbox"]',
                                'preload-editor-import-summary'
                    updateSummary
                            );
                );


                    const updateSummary =
                const buttons = $('<div>')
                        () => {
                    .addClass('preload-editor-prompt-buttons');
                            const selected =
                                list.find(
                                    'input[type="checkbox"]:checked'
                                ).length;


                            const existingSelected =
                const cancel = this.createButton(
                                list.find(
                    'Cancel',
                                    'input[type="checkbox"]:checked'
                    '',
                                ).filter(
                    () => {
                                    function () {
                        overlay.remove();
                                        const name =
                        resolve(null);
                                            $(this)
                    }
                                                .attr(
                );
                                                    'data-import-variable'
                                                );


                                        return !!(
                const importButton = this.createButton(
                                            localFields[
                    'Import selected',
                                                name
                    'preload-editor-button-primary',
                                            ]
                    () => {
                                        );
                        const selected = [];
                                    }
                                ).length;


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


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


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


                            importButton.prop(
                $(document.body).append(overlay);
                                'disabled',
                                selected === 0
                            );
                        };


                    const filterRows =
                overlay.attr('tabindex', '-1');
                        () => {
                overlay.trigger('focus');
                            const query =
                                String(
                                    search.val() || ''
                                )
                                    .trim()
                                    .toLowerCase();


                            list
                updateSummary();
                                .find(
                search.trigger('focus');
                                    '.preload-editor-import-row'
                                )
                                .each(
                                    function () {
                                        const row =
                                            $(this);


                                        const haystack =
                const close = event => {
                                            String(
                    if (event.key === 'Escape') {
                                                row.attr(
                        cancel.trigger('click');
                                                    'data-search'
                    }
                                                ) || ''
                };
                                            );


                                        row.toggle(
                overlay.on(
                                            !query ||
                    'keydown.preloadEditor',
                                            haystack.includes(
                    close
                                                query
                );
                                            )
                                        );
                                    }
                                );
                        };


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


                    list.on(
        /*
                        'change.preloadEditor',
        * Main import workflow.
                        'input[type="checkbox"]',
        */
                        updateSummary
        async importVariableDefinitions() {
                    );
            const source = await this.createPromptDialog(
                'Import variable definitions',
                'Enter a preload title or its config.json title.'
            );


                    const buttons =
            if (!source) {
                        $('<div>')
                return;
                            .addClass(
            }
                                'preload-editor-prompt-buttons'
                            );


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


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


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


                                overlay.remove();
                if (!page) {
                                resolve(selected);
                    throw new Error(
                            }
                         'No config.json was found at "' +
                         );
                         configTitle +
 
                         '".'
                    buttons.append(
                         cancel,
                         importButton
                     );
                     );
                }
                let sourceConfig;


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


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


                     $(document.body)
                if (
                        .append(overlay);
                    !sourceConfig.fields ||
 
                     typeof sourceConfig.fields !== 'object' ||
                     overlay.attr(
                    Array.isArray(sourceConfig.fields)
                         'tabindex',
                ) {
                        '-1'
                     throw new Error(
                         'The source config.json does not contain a valid "fields" object.'
                     );
                     );
                }


                    overlay.trigger('focus');
                const fields = this.getImportableFields(sourceConfig);


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


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


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


                     overlay.on(
                if (!selected.length) {
                         'keydown.preloadEditor',
                     this.setStatus(
                         close
                         'No variable definitions were imported.',
                     );
                         'warning'
                     );
 
                    return;
                }


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


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


            if (!source) {
                const imported = [];
                 return;
                 const replaced = [];
            }


            const configTitle =
                 selected.forEach(name => {
                 this.normalizeConfigTitle(
                     if (name === 'PAGE') {
                     source
                        return;
                );
                    }


            this.setStatus(
                    const field = fields[name];
                'Loading source config...',
                'loading'
            );


            try {
                     if (!field) {
                const page =
                         return;
                     await this.getPage(
                     }
                         configTitle
                     );


                if (!page) {
                    if (this.state.config.fields[name]) {
                    throw new Error(
                         replaced.push(name);
                         'No config.json was found at "' +
                    } else {
                        configTitle +
                         imported.push(name);
                         '".'
                    }
                    );
                }


                let sourceConfig;
                    const clone = this.cloneConfigField(field);


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


                if (
                     this.state.config.fields[name] = clone;
                     !sourceConfig ||
                    typeof sourceConfig !== 'object' ||
                    Array.isArray(sourceConfig)
                ) {
                    throw new Error(
                        'The source config.json must contain a JSON object.'
                    );
                }


                if (
                    /*
                    !sourceConfig.fields ||
                    * If this variable was previously deleted,
                    typeof sourceConfig.fields !== 'object' ||
                    * importing its definition explicitly should
                     Array.isArray(sourceConfig.fields)
                    * restore it.
                ) {
                    */
                    throw new Error(
                     if (Array.isArray(this.state.deletedVariables)) {
                        'The source config.json does not contain a valid "fields" object.'
                        this.state.deletedVariables =
                    );
                            this.state.deletedVariables.filter(
                 }
                                deletedName =>
                                    String(deletedName).toUpperCase() !== name
                            );
                    }
                 });


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


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


                 if (
                 if (
                     selected === null
                     !selectedStillExists ||
                    this.state.selectedVariable === 'PAGE'
                 ) {
                 ) {
                     this.setStatus(
                     this.state.selectedVariable =
                         'Variable import cancelled.',
                         selected[0] ||
                         'warning'
                         'PAGE';
                    );
 
                    return;
                 }
                 }


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


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


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


                 if (
                 let status =
                     !this.state.config.fields
                    'Imported ' +
                ) {
                     selected.length +
                     this.state.config.fields = {};
                    ' variable ' +
                }
                    (selected.length === 1
                        ? 'definition'
                        : 'definitions') +
                     '.';


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


                 selected.forEach(
                 this.setStatus(status, 'success');
                    name => {
                        if (
                            name === 'PAGE'
                        ) {
                            return;
                        }


                        const field =
                mw.notify(status, {
                            fields[name];
                    type: 'success'
                });
            } catch (error) {
                console.error(
                    'Preload Editor variable import:',
                    error
                );


                        if (
                this.setStatus(
                            !field
                    error.message ||
                         ) {
                         'Could not import variable definitions.',
                            return;
                    'error'
                        }
                );


                        if (
                mw.notify(
                            this.state.config.fields[
                    error.message ||
                                name
                        'Could not import variable definitions.',
                            ]
                    {
                         ) {
                         type: 'error'
                            replaced.push(
                    }
                                name
                );
                            );
            }
                        } else {
        },
                            imported.push(
                                name
                            );
                        }


                        const clone =
        // =========================================================
                            this.cloneConfigField(
        // Preload parsing
                                field
        // =========================================================
                            );


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


                        this.state.config.fields[
            return match
                            name
                ? match[1].trim()
                        ] = clone;
                : String(source || '').trim();
        },


                        /*
        findVariables(content) {
                        * If this variable was previously deleted,
            const variables = [];
                        * importing its definition explicitly should
            const pattern = /%%([A-Za-z0-9_-]+)%%/g;
                        * restore it.
                        */
                        if (
                            Array.isArray(
                                this.state.deletedVariables
                            )
                        ) {
                            this.state.deletedVariables =
                                this.state.deletedVariables
                                    .filter(
                                        deletedName =>
                                            String(
                                                deletedName
                                            ).toUpperCase() !==
                                            name
                                    );
                        }
                    }
                );


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


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


                /*
                 if (!variables.includes(name)) {
                * Keep the current selection when possible.
                     variables.push(name);
                * 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();
            return variables;
        },


                 this.renderVariablesBySelector();
        findConditions(content) {
                this.renderVariableConfigurationBySelector();
            const conditions = [];
                 this.renderAnalysisBySelector();
            const pattern =
                this.updateEditorTools();
                 /<!--\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
                });
            }


                this.saveDraft();
            return conditions;
        },


                let status =
        findConditionVariables(content) {
                    'Imported ' +
            const variables = [];
                    selected.length +
                    ' variable ' +
                    (
                        selected.length === 1
                            ? 'definition'
                            : 'definitions'
                    ) +
                    '.';


            this.findConditions(content).forEach(condition => {
                 if (
                 if (
                     replaced.length
                     condition.type !== 'if' &&
                    condition.type !== 'elseif'
                 ) {
                 ) {
                     status +=
                     return;
                        ' Replaced: ' +
                        replaced.join(', ') +
                        '.';
                 }
                 }


                 this.setStatus(
                 const matches =
                     status,
                    condition.expression.match(
                    'success'
                        /[A-Za-z0-9_-]+/g
                 );
                     ) || [];
 
                 matches.forEach(name => {
                    name = name.toUpperCase();


                mw.notify(
                    if (!variables.includes(name)) {
                    status,
                         variables.push(name);
                    {
                         type: 'success'
                     }
                     }
                 );
                 });
             } catch (error) {
             });
                console.error(
 
                    'Preload Editor variable import:',
            return variables;
                    error
        },
                );


                 this.setStatus(
        analyzePreload(content) {
                    error.message ||
            return {
                        'Could not import variable definitions.',
                 variables: this.findVariables(content),
                    'error'
                 conditions: this.findConditions(content),
                 );
                conditionVariables:
 
                     this.findConditionVariables(content)
                mw.notify(
             };
                    error.message ||
                        'Could not import variable definitions.',
                    {
                        type: 'error'
                     }
                );
             }
         },
         },


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


         extractIncludeonly(source) {
         getAutomaticPageField() {
             const match =
             return {
                 String(source || '').match(
                 label: 'Page Name',
                    /<includeonly>([\s\S]*?)<\/includeonly>/i
                description: 'Name of the page.',
                 );
                type: 'page',
 
                 required: true,
            return match
                 namespace: 'Main',
                 ? match[1].trim()
                 'allow-new': true
                 : String(source || '').trim();
            };
         },
         },


         findVariables(content) {
         emptyConfig() {
             const variables = [];
             return {
            const pattern =
                settings: {
                 /%%([A-Za-z0-9_-]+)%%/g;
                    incomplete: 'add-stub'
                 },


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


             while (
        normalizeConfig(config) {
                 (match = pattern.exec(content)) !== null
             if (
                 !config ||
                typeof config !== 'object' ||
                Array.isArray(config)
             ) {
             ) {
                 const name =
                 config = this.emptyConfig();
                    match[1].toUpperCase();
            }


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


             return variables;
             if (
        },
                !config.fields ||
 
                 typeof config.fields !== 'object' ||
        findConditions(content) {
                 Array.isArray(config.fields)
            const conditions = [];
            const pattern =
                 /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
 
            let match;
 
            while (
                 (match = pattern.exec(content)) !== null
             ) {
             ) {
                 conditions.push({
                 config.fields = {};
                    type:
            }
                        match[1].toLowerCase(),


                    expression:
            config.fields.PAGE =
                        match[2].trim(),
                this.getAutomaticPageField();


                    index:
            return config;
                        match.index
        },
                });
            }


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


         findConditionVariables(content) {
         humanizeName(name) {
             const variables = [];
            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()
                    )
                    : []
            );


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


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


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


                            if (
                fields[name] =
                                !variables.includes(
                    existing[name] ||
                                    name
                    this.createDefaultField(
                                )
                        name,
                            ) {
                         analysis
                                variables.push(
                                    name
                                );
                            }
                         }
                     );
                     );
                }
            });
            );


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


        analyzePreload(content) {
                if (
            return {
                    upper !== 'PAGE' &&
                 variables:
                    !fields[upper] &&
                    this.findVariables(content),
                    !deleted.has(upper)
                ) {
                    fields[upper] =
                        existing[name];
                 }
            });


                conditions:
            config.fields = fields;
                    this.findConditions(content),


                conditionVariables:
             return config;
                    this.findConditionVariables(
                        content
                    )
             };
         },
         },


         // =========================================================
         validateConfig(config, analysis) {
        // Config
             const errors = [];
        // =========================================================
             const warnings = [];
 
        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 (
             if (
                 !config ||
                 !config ||
Line 1,902: Line 1,378:
                 Array.isArray(config)
                 Array.isArray(config)
             ) {
             ) {
                 config =
                 return {
                     this.emptyConfig();
                     valid: false,
            }
                    errors: [
 
                        'Config must be an object.'
            if (
                    ],
                !config.settings ||
                    warnings: []
                typeof config.settings !== 'object' ||
                 };
                Array.isArray(config.settings)
            ) {
                 config.settings = {};
             }
             }


Line 1,919: Line 1,392:
                 Array.isArray(config.fields)
                 Array.isArray(config.fields)
             ) {
             ) {
                 config.fields = {};
                 return {
             }
                    valid: false,
                    errors: [
                        '"fields" must be an object.'
                    ],
                    warnings
                };
             }
 
            const configured =
                Object.keys(
                    config.fields
                ).map(
                    name =>
                        name.toUpperCase()
                );


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


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


        createDefaultField(
            if (!configured.includes('PAGE')) {
            name,
                 errors.push(
            analysis
                     'PAGE must always be configured.'
        ) {
                );
            return {
            }
                 label:
                     this.humanizeName(name),


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


                type:
                     return;
                    analysis.conditionVariables.includes(
                }
                        name
                     )
                        ? 'boolean'
                        : 'text'
            };
        },


        humanizeName(name) {
                if (!field.type) {
            return String(name || '')
                     warnings.push(
                .replace(
                        'Field "' +
                     /[_-]+/g,
                        name +
                    ' '
                        '" has no type.'
                )
                     );
                .replace(
                 }
                    /([a-z])([A-Z])/g,
                  
                    '$1 $2'
                 if (
                )
                     field.type === 'file'
                .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 (
                     if (
                         name === 'PAGE' ||
                         field.accept !== undefined &&
                         deleted.has(name)
                         !Array.isArray(field.accept)
                     ) {
                     ) {
                         return;
                         errors.push(
                            'File field "' +
                            name +
                            '" must have "accept" as an array.'
                        );
                     }
                     }


                     fields[name] =
                     if (
                        existing[name] ||
                        Array.isArray(field.accept) &&
                         this.createDefaultField(
                        field.accept.some(
                             name,
                            value =>
                             analysis
                                !String(
                                    value || ''
                                ).trim()
                         )
                    ) {
                        errors.push(
                            'File field "' +
                             name +
                             '" contains an empty accepted MIME type.'
                         );
                         );
                }
                    }
            );
 
            Object.keys(existing).forEach(
                name => {
                    const upper =
                        name.toUpperCase();


                     if (
                     if (
                         upper !== 'PAGE' &&
                         field.filename !== undefined &&
                         !fields[upper] &&
                         typeof field.filename !== 'string'
                        !deleted.has(upper)
                     ) {
                     ) {
                         fields[upper] =
                         errors.push(
                             existing[name];
                            'File field "' +
                            name +
                            '" must have "filename" as a string.'
                        );
                    }
 
                    if (
                        field.filetext !== undefined &&
                        typeof field.filetext !== 'string'
                    ) {
                        errors.push(
                            'File field "' +
                            name +
                            '" must have "filetext" as a string.'
                        );
                    }
 
                    if (
                        !field.accept ||
                        !field.accept.length
                    ) {
                        warnings.push(
                            'File field "' +
                            name +
                            '" has no accepted MIME types.'
                        );
                    }
 
                    if (
                        !field.filename
                    ) {
                        warnings.push(
                            'File field "' +
                             name +
                            '" has no filename template.'
                        );
                     }
                     }
                 }
                 }
            );


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


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


        validateConfig(
                    if (
            config,
                        field.filetext !== undefined &&
            analysis
                        typeof field.filetext !== 'string'
        ) {
                    ) {
            const errors = [];
                        errors.push(
            const warnings = [];
                            'File field "' +
                            name +
                            '" has an invalid "filetext" value.'
                        );
                    }


            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 (
                     if (
                         !configured.includes(name)
                         field.accept !== undefined &&
                        (
                            !Array.isArray(field.accept) ||
                            field.accept.some(
                                value =>
                                    typeof value !== 'string' ||
                                    !value.trim()
                            )
                        )
                     ) {
                     ) {
                         errors.push(
                         errors.push(
                             'Variable "' +
                             'File field "' +
                             name +
                             name +
                             '" is not configured.'
                             '" must have "accept" as an array of MIME types.'
                         );
                         );
                     }
                     }
                 }
                 }
             );
             });


            configured.forEach(
             return {
                name => {
                 valid: !errors.length,
                    if (
                 errors,
                        !analysis.variables.includes(
                 warnings
                            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.'
                        );
                    }
                }
            );
 
             return {
                 valid:
                    !errors.length,
                 errors,
                 warnings
             };
             };
         },
         },
Line 2,222: Line 1,649:
         // =========================================================
         // =========================================================


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


             $('<h2>')
             $('<h2>')
                 .addClass(
                 .addClass('preload-editor-section-title')
                    'preload-editor-section-title'
                )
                 .text(title)
                 .text(title)
                 .appendTo(section);
                 .appendTo(section);
Line 2,246: Line 1,665:
         },
         },


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


             if (className) {
             if (className) {
Line 2,276: Line 1,685:
         },
         },


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


             $('<label>')
             $('<label>')
                 .addClass(
                 .addClass('preload-editor-label')
                    'preload-editor-label'
                )
                 .text(label)
                 .text(label)
                 .appendTo(row);
                 .appendTo(row);
Line 2,296: Line 1,696:
             if (description) {
             if (description) {
                 $('<div>')
                 $('<div>')
                     .addClass(
                     .addClass('preload-editor-help')
                        'preload-editor-help'
                    )
                     .text(description)
                     .text(description)
                     .appendTo(row);
                     .appendTo(row);
Line 2,308: Line 1,706:
         },
         },


         textInput(
         textInput(value, placeholder) {
            value,
            placeholder
        ) {
             return $('<input>')
             return $('<input>')
                 .attr({
                 .attr({
                     type: 'text',
                     type: 'text',
                     value:
                     value: value == null ? '' : value,
                        value == null
                     placeholder: placeholder || ''
                            ? ''
                            : value,
 
                     placeholder:
                        placeholder || ''
                 })
                 })
                 .addClass(
                 .addClass('preload-editor-input');
                    'preload-editor-input'
                );
         },
         },


         textarea(value) {
         textarea(value) {
             return $('<textarea>')
             return $('<textarea>')
                 .val(
                 .val(value == null ? '' : value)
                    value == null
                 .addClass('preload-editor-textarea');
                        ? ''
                        : value
                )
                 .addClass(
                    'preload-editor-textarea'
                );
         },
         },


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


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


             return select;
             return select;
Line 2,403: Line 1,758:
             cancelLabel
             cancelLabel
         ) {
         ) {
             return new Promise(
             return new Promise(resolve => {
                resolve => {
                const overlay = $('<div>')
                    const overlay =
                    .addClass('preload-editor-prompt-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(
                const box = $('<div>')
                        line => {
                    .addClass('preload-editor-prompt');
                            if (line === '') {
                                $('<div>')
                                    .addClass(
                                        'preload-editor-prompt-spacer'
                                    )
                                    .appendTo(box);


                                return;
                $('<h2>')
                            }
                    .text(title)
                    .appendTo(box);


                            $('<p>')
                const messageLines =
                                .text(line)
                    String(message || '').split('\n');
                                .appendTo(box);
                        }
                    );


                     const buttons =
                messageLines.forEach(line => {
                     if (line === '') {
                         $('<div>')
                         $('<div>')
                             .addClass(
                             .addClass(
                                 'preload-editor-prompt-buttons'
                                 'preload-editor-prompt-spacer'
                             );
                             )
                            .appendTo(box);


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


                     const confirm =
                     $('<p>')
                        this.createButton(
                        .text(line)
                            confirmLabel || 'Confirm',
                        .appendTo(box);
                            'preload-editor-button-primary',
                });
                            () => {
                                overlay.remove();
                                resolve(true);
                            }
                        );


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


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


                     $(document.body)
                const confirm = this.createButton(
                         .append(overlay);
                    confirmLabel || 'Confirm',
                    'preload-editor-button-primary',
                     () => {
                        overlay.remove();
                         resolve(true);
                    }
                );


                    const close =
                buttons.append(cancel, confirm);
                        value => {
                box.append(buttons);
                            if (value === 'Escape') {
                overlay.append(box);
                                cancel.trigger('click');
 
                            }
                $(document.body).append(overlay);
                        };


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


                    overlay.attr(
                overlay.on(
                        'tabindex',
                    'keydown.preloadEditor',
                         '-1'
                    event => {
                     );
                         close(event.key);
                     }
                );


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


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


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


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


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


                    $('<p>')
                $('<p>')
                        .text(message)
                    .text(message)
                        .appendTo(box);
                    .appendTo(box);


                    const input =
                const input = this.textInput();
                        this.textInput();


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


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


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


                                if (!value) {
                        if (!value) {
                                    return;
                            return;
                                }
                        }


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


                    buttons.append(
                buttons.append(cancel, confirm);
                        cancel,
                        confirm
                    );


                    box.append(
                box.append(
                        input,
                    input,
                        buttons
                    buttons
                    );
                );


                    overlay.append(box);
                overlay.append(box);


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


                    input.trigger('focus');
                input.trigger('focus');


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


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


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


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


                    $('<h2>')
                $('<h2>')
                        .text('Review config.json')
                    .text('Review config.json')
                        .appendTo(box);
                    .appendTo(box);


                    $('<p>')
                $('<p>')
                        .text(
                    .text(
                            'This is the config.json that will be saved for the current preload.'
                        'This is the config.json that will be saved for the current preload.'
                        )
                    )
                        .appendTo(box);
                    .appendTo(box);


                    const code =
                const code = $('<pre>')
                        $('<pre>')
                    .addClass(
                            .addClass(
                        'preload-editor-config-review'
                                'preload-editor-config-review'
                    )
                            )
                    .text(this.getConfigJSON());
                            .text(
                                this.getConfigJSON()
                            );


                    const codeWrap =
                const codeWrap = $('<div>')
                        $('<div>')
                    .addClass(
                            .addClass(
                        'preload-editor-config-review-wrap'
                                'preload-editor-config-review-wrap'
                    )
                            )
                    .append(code);
                            .append(code);


                    box.append(codeWrap);
                box.append(codeWrap);


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


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


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


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


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


                     overlay.trigger('focus');
                overlay.on(
                     'keydown.preloadEditor',
                    event => {
                        if (event.key === 'Escape') {
                            close.trigger('click');
                        }
                    }
                );


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


                    overlay.on(
         async confirmSave(mode, title) {
                        'click.preloadEditor',
                        event => {
                            if (
                                event.target ===
                                overlay.get(0)
                            ) {
                                close.trigger('click');
                            }
                        }
                    );
                }
            );
        },
 
         async confirmSave(
            mode,
            title
        ) {
             const configTitle =
             const configTitle =
                 title.replace(
                 title.replace(/\/+$/, '') +
                    /\/+$/,
                    ''
                ) +
                 '/config.json';
                 '/config.json';


Line 2,740: Line 2,023:
             }
             }


             let pages = [];
             const pages = [];


             if (
             if (mode === 'preload' || mode === 'both') {
                mode === 'preload' ||
                mode === 'both'
            ) {
                 pages.push(
                 pages.push(
                     'Preload: ' + title
                     'Preload: ' +
                    title
                 );
                 );
             }
             }


             if (
             if (mode === 'config' || mode === 'both') {
                mode === 'config' ||
                mode === 'both'
            ) {
                 pages.push(
                 pages.push(
                     'Config: ' + configTitle
                     'Config: ' +
                    configTitle
                 );
                 );


Line 2,781: Line 2,060:
         // CodeMirror
         // CodeMirror
         // =========================================================
         // =========================================================
 
                destroyEditor() {
        destroyEditor() {
             if (!this.editor) {
             if (!this.editor) {
                 this.editorView = null;
                 this.editorView = null;
Line 2,794: Line 2,072:
             } = this.editor;
             } = this.editor;


             if (
             if (view && inputHandler) {
                view &&
                inputHandler
            ) {
                 view.dom.removeEventListener(
                 view.dom.removeEventListener(
                     'input',
                     'input',
Line 2,825: Line 2,100:
             }
             }


             return (
             return this.state.preloadContent || '';
                this.state.preloadContent || ''
            );
         },
         },


         setEditorContent(content) {
         setEditorContent(content) {
             content =
             content = String(content || '');
                String(content || '');


             if (!this.editorView) {
             if (!this.editorView) {
                 this.state.preloadContent =
                 this.state.preloadContent = content;
                    content;
                 return;
                 return;
             }
             }
Line 2,850: Line 2,121:
                 changes: {
                 changes: {
                     from: 0,
                     from: 0,
                     to:
                     to: this.editorView.state.doc.length,
                        this.editorView.state.doc.length,
                     insert: content
                     insert: content
                 }
                 }
Line 2,866: Line 2,136:


             const selection =
             const selection =
                 this.editorView.state
                 this.editorView.state.selection.main;
                    .selection.main;


             return {
             return {
                 from:
                 from: selection.from,
                to: selection.to,
                text: this.editorView.state.doc.sliceString(
                     selection.from,
                     selection.from,
 
                     selection.to
                to:
                 )
                     selection.to,
 
                 text:
                    this.editorView.state.doc.sliceString(
                        selection.from,
                        selection.to
                    )
             };
             };
         },
         },
Line 2,900: Line 2,164:
             }
             }


             const view =
             const view = this.editorView;
                this.editorView;
             const selection = view.state.selection.main;
 
             const selection =
                view.state.selection.main;


             const from =
             const from =
Line 2,927: Line 2,188:
                     insert: text
                     insert: text
                 },
                 },
                 selection: {
                 selection: {
                     anchor: cursor
                     anchor: cursor
Line 2,961: Line 2,221:
                 selected.from,
                 selected.from,
                 selected.to,
                 selected.to,
                 selected.from +
                 selected.from + replacement.length
                    replacement.length
             );
             );
         },
         },


         insertVariable(name) {
         insertVariable(name) {
             name =
             name = String(name || '')
                String(name || '')
                .trim()
                    .trim()
                .toUpperCase();
                    .toUpperCase();


             if (!name) {
             if (!name) {
Line 2,994: Line 2,252:
             variable
             variable
         ) {
         ) {
             type =
             type = String(type || '').toLowerCase();
                String(type || '')
             variable = String(variable || '')
                    .toLowerCase();
                .trim()
 
                .toUpperCase();
             variable =
                String(variable || '')
                    .trim()
                    .toUpperCase();


             const selected =
             const selected =
Line 3,038: Line 2,292:
                             closing;
                             closing;


                         cursorOffset =
                         cursorOffset = text.length;
                            text.length;
                     } else {
                     } else {
                         text =
                         text =
Line 3,056: Line 2,309:
                     text =
                     text =
                         '<!-- @elseif -->';
                         '<!-- @elseif -->';
                     cursorOffset =
                     cursorOffset =
                         text.length;
                         text.length;
                     break;
                     break;


Line 3,063: Line 2,318:
                     text =
                     text =
                         '<!-- @else -->';
                         '<!-- @else -->';
                     cursorOffset =
                     cursorOffset =
                         text.length;
                         text.length;
                     break;
                     break;


Line 3,070: Line 2,327:
                     text =
                     text =
                         '<!-- @endif -->';
                         '<!-- @endif -->';
                     cursorOffset =
                     cursorOffset =
                         text.length;
                         text.length;
                     break;
                     break;


Line 3,082: Line 2,341:
                 selected.from,
                 selected.from,
                 selected.to,
                 selected.to,
                 selected.from +
                 selected.from + cursorOffset
                    cursorOffset
             );
             );
         },
         },
Line 3,098: Line 2,356:
                     name => {
                     name => {
                         variables.add(
                         variables.add(
                             String(
                             String(name).toUpperCase()
                                name
                         );
                            ).toUpperCase()
                         );
                     }
                     }
                 );
                 );
Line 3,156: Line 2,412:
                     .val();
                     .val();


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


                     variables.forEach(
                if (
                        name => {
                    value &&
                            $('<option>')
                     variables.includes(value)
                                .attr(
                ) {
                                    'value',
                    select.val(value);
                                    name
                } else if (
                                )
                    variables.length
                                .text(name)
                ) {
                                .appendTo(select);
                    select.val(
                         }
                         variables[0]
                     );
                     );
 
                }
                    if (
            };
                        value &&
                        variables.includes(value)
                    ) {
                        select.val(value);
                    } else if (
                        variables.length
                    ) {
                        select.val(
                            variables[0]
                        );
                    }
                };


             populate(
             populate(
Line 3,392: Line 2,647:
                     name => {
                     name => {
                         known.add(
                         known.add(
                             String(
                             String(name).toUpperCase()
                                name
                            ).toUpperCase()
                         );
                         );
                     }
                     }
Line 3,422: Line 2,675:
                 (
                 (
                     match =
                     match =
                         variablePattern.exec(
                         variablePattern.exec(source)
                            source
                        )
                 ) !== null
                 ) !== null
             ) {
             ) {
Line 3,455: Line 2,706:
                 (
                 (
                     match =
                     match =
                         conditionPattern.exec(
                         conditionPattern.exec(source)
                            source
                        )
                 ) !== null
                 ) !== null
             ) {
             ) {
Line 3,540: Line 2,789:
                             start,
                             start,
                             start +
                             start +
                                 expressionMatch[0]
                                 expressionMatch[0].length
                                    .length
                         )
                         )
                     );
                     );
Line 3,614: Line 2,862:
                     let settled = false;
                     let settled = false;


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


                            mw.hook(
                        mw.hook(
                                'dev.CCM.load'
                            'dev.CCM.load'
                            ).remove(onLoad);
                        ).remove(onLoad);
                        };
                    };


                     const onReady =
                     const onReady =
Line 3,777: Line 3,024:
             );
             );
         },
         },
 
                renderShell() {
        renderShell() {
             this.destroyEditor();
             this.destroyEditor();
             this.root.empty();
             this.root.empty();
Line 5,508: Line 4,754:
                 ['page', 'Page'],
                 ['page', 'Page'],
                 ['user', 'User'],
                 ['user', 'User'],
                 ['date', 'Date']
                 ['date', 'Date'],
                ['file', 'File']
             ];
             ];


Line 5,676: Line 4,923:
             ) {
             ) {
                 this.renderSelectConfiguration(
                 this.renderSelectConfiguration(
                    card,
                    field
                );
            }
            if (
                field.type === 'file'
            ) {
                this.renderFileConfiguration(
                     card,
                     card,
                     field
                     field
Line 5,939: Line 5,195:
                     'preload-editor-small-textarea'
                     'preload-editor-small-textarea'
                 );
                 );
 
 
             options.on(
             options.on(
                 'input.preloadEditor',
                 'input.preloadEditor',
                 () => {
                 () => {
                     const lines =
                     const lines =
                         options
                         options
                             .val()
                             .val()
                             .split('\n')
                             .split('\n')
                             .map(
                             .map(
                                 line =>
                                 line =>
                                     line.trim()
                                     line.trim()
                             )
                             )
                             .filter(Boolean);
                             .filter(Boolean);
 
 
                     if (!lines.length) {
                     if (!lines.length) {
                         delete field.options;
                         delete field.options;
                    } else {
                        field.options =
                            lines.map(
                                line => {
                                    const parts =
                                        line.split(
                                            /\s*\|\s*/
                                        );
 
                                    if (
                                        parts.length ===
                                        1
                                    ) {
                                        return parts[0];
                                    }
 
                                    return {
                                        value:
                                            parts[0],
 
                                        label:
                                            parts
                                                .slice(1)
                                                .join(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Manual options',
                    options,
                    'One per line. Optional label format: value | label. Leave empty when using a source.'
                )
            );
        },
       
                renderFileConfiguration(
            card,
            field
        ) {
            const acceptValue =
                Array.isArray(field.accept)
                    ? field.accept.join('\n')
                    : '';
 
            const accept =
                this.textarea(
                    acceptValue
                ).addClass(
                    'preload-editor-small-textarea'
                );
 
            accept.on(
                'input.preloadEditor',
                () => {
                    const values =
                        accept
                            .val()
                            .split('\n')
                            .map(
                                value =>
                                    value.trim()
                            )
                            .filter(Boolean);
 
                    if (values.length) {
                        field.accept = values;
                    } else {
                        delete field.accept;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Accepted MIME types',
                    accept,
                    'One MIME type per line. Example: image/png'
                )
            );
 
            const filename =
                this.textInput(
                    field.filename ||
                    '%%PAGE%%.%%EXT%%'
                );
 
            filename.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filename.val();
 
                    if (value) {
                        field.filename = value;
                    } else {
                        delete field.filename;
                    }
 
                    this.markDirty();
                    this.renderConfigReviewAvailability();
                }
            );
 
            card.append(
                this.fieldRow(
                    'Filename',
                    filename,
                    'Filename template. %%PAGE%% is replaced with the page name and %%EXT%% with the uploaded file extension.'
                )
            );
 
            const filetext =
                this.textarea(
                    field.filetext || ''
                );
 
            filetext.on(
                'input.preloadEditor',
                () => {
                    const value =
                        filetext.val();
 
                    if (value) {
                        field.filetext = value;
                     } else {
                     } else {
                         field.options =
                         delete field.filetext;
                            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(
                                                    ' | '
                                                )
                                    };
                                }
                            );
                     }
                     }


Line 5,993: Line 5,359:
             card.append(
             card.append(
                 this.fieldRow(
                 this.fieldRow(
                     'Manual options',
                     'File page text',
                     options,
                     filetext,
                     'One per line. Optional label format: value | label. Leave empty when using a source.'
                     'Wikitext used for the uploaded File: page.'
                 )
                 )
             );
             );
Line 6,776: Line 6,142:


             let depth = 0;
             let depth = 0;
 
                        analysis.conditions.forEach(
            analysis.conditions.forEach(
                 condition => {
                 condition => {
                     if (
                     if (
Line 6,849: Line 6,214:
         if (
         if (
             mw.config.get('wgPageName') !==
             mw.config.get('wgPageName') !==
             'Petit_Planet:Preload_Editor'
             'Petit_Planet_Wiki:Preload_Editor'
         ) {
         ) {
             return;
             return;