MediaWiki:Gadget-PreloadEditor.js
MediaWiki interface page
More actions
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
(function (mw, $) {
'use strict';
const PreloadEditor = {
version: '0.1.0',
state: {
preloadTitle: '',
preloadSource: '',
preloadContent: '',
config: null,
variables: [],
conditions: [],
mode: 'idle',
dirty: false
},
defaults: {
preload:
'<includeonly>\n' +
'<!-- preload here -->\n' +
'</includeonly>' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
},
/* =========================================================
* API
* ========================================================= */
async getPage(title) {
const api = new mw.Api();
const response = await api.get({
action: 'query',
prop: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
});
const page =
response &&
response.query &&
response.query.pages &&
response.query.pages[0];
if (!page || page.missing) {
return null;
}
const content =
page.revisions &&
page.revisions[0] &&
page.revisions[0].slots &&
page.revisions[0].slots.main &&
page.revisions[0].slots.main.content;
return {
title: page.title || title,
content:
typeof content === 'string'
? content
: ''
};
},
async pageExists(title) {
const page = await this.getPage(title);
return !!page;
},
async searchPages(query) {
const api = new mw.Api();
const response = await api.get({
action: 'query',
list: 'search',
srsearch: query,
srlimit: 10,
srnamespace: 10,
formatversion: 2
});
return (
response &&
response.query &&
response.query.search
? response.query.search
: []
);
},
async savePage(title, content, summary, createOnly) {
const api = new mw.Api();
const params = {
action: 'edit',
title: title,
text: content,
summary:
summary ||
'Updated with Preload Editor',
formatversion: 2
};
if (createOnly) {
params.createonly = true;
}
return api.postWithToken(
'csrf',
params
);
},
/* =========================================================
* PRELOAD PARSING
* ========================================================= */
extractIncludeonly(source) {
const match =
String(source || '').match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
if (!match) {
return String(source || '').trim();
}
return match[1].trim();
},
findVariables(content) {
const variables = [];
const pattern =
/%%([A-Za-z0-9_-]+)%%/g;
let match;
while (
(match = pattern.exec(content)) !== null
) {
const name =
match[1].toUpperCase();
if (!variables.includes(name)) {
variables.push(name);
}
}
return variables;
},
findConditions(content) {
const conditions = [];
const pattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
let match;
while (
(match = pattern.exec(content)) !== null
) {
conditions.push({
type:
match[1].toLowerCase(),
expression:
match[2].trim(),
index:
match.index
});
}
return conditions;
},
findConditionVariables(content) {
const variables = [];
const conditions =
this.findConditions(content);
conditions.forEach(condition => {
if (
condition.type !== 'if' &&
condition.type !== 'elseif'
) {
return;
}
const matches =
condition.expression.match(
/[A-Za-z0-9_-]+/g
) || [];
matches.forEach(name => {
const upper =
name.toUpperCase();
if (
!variables.includes(upper)
) {
variables.push(upper);
}
});
});
return variables;
},
analyzePreload(content) {
const variables =
this.findVariables(content);
const conditions =
this.findConditions(content);
const conditionVariables =
this.findConditionVariables(content);
return {
variables: variables,
conditions: conditions,
conditionVariables:
conditionVariables
};
},
/* =========================================================
* CONFIG
* ========================================================= */
emptyConfig() {
return {
settings: {
incomplete: 'add-stub'
},
fields: {
PAGE: {
label: 'Page Name',
description:
'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
}
}
};
},
normalizeConfig(config) {
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
config =
this.emptyConfig();
}
if (
!config.settings ||
typeof config.settings !== 'object' ||
Array.isArray(config.settings)
) {
config.settings = {};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
config.fields = {};
}
/*
* PAGE is always present.
*
* Existing PAGE configuration is preserved.
*/
if (!config.fields.PAGE) {
config.fields.PAGE = {
label: 'Page Name',
description:
'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
}
return config;
},
createDefaultField(name, analysis) {
const isConditionVariable =
analysis.conditionVariables.includes(
name
);
let type = 'text';
/*
* Variables used directly as conditions
* are most commonly booleans.
*
* This is only a suggestion; the editor
* can change it.
*/
if (isConditionVariable) {
type = 'boolean';
}
return {
label: this.humanizeName(name),
description: '',
type: type
};
},
humanizeName(name) {
return String(name || '')
.replace(/[_-]+/g, ' ')
.replace(
/([a-z])([A-Z])/g,
'$1 $2'
)
.replace(/\s+/g, ' ')
.trim()
.replace(
/\b\w/g,
char => char.toUpperCase()
);
},
prepareConfigForVariables(
config,
analysis
) {
config =
this.normalizeConfig(config);
const existing =
config.fields || {};
const fields = {};
/*
* PAGE is deliberately first.
*/
fields.PAGE =
existing.PAGE || {
label: 'Page Name',
description:
'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
analysis.variables.forEach(name => {
if (name === 'PAGE') {
return;
}
fields[name] =
existing[name] ||
this.createDefaultField(
name,
analysis
);
});
/*
* Preserve fields that exist in the config
* but are no longer present in the preload.
*
* They are shown as unused rather than deleted.
*/
Object.keys(existing).forEach(name => {
const upper =
name.toUpperCase();
if (!fields[upper]) {
fields[upper] =
existing[name];
}
});
config.fields = fields;
return config;
},
validateConfig(config, analysis) {
const errors = [];
const warnings = [];
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
return {
valid: false,
errors: [
'Config must be an object.'
],
warnings: []
};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
errors.push(
'"fields" must be an object.'
);
return {
valid: false,
errors: errors,
warnings: warnings
};
}
const configured =
Object.keys(config.fields)
.map(name =>
name.toUpperCase()
);
analysis.variables.forEach(name => {
if (
!configured.includes(name)
) {
errors.push(
'Variable "' +
name +
'" is not configured.'
);
}
});
configured.forEach(name => {
if (
!analysis.variables.includes(
name
)
) {
warnings.push(
'Field "' +
name +
'" is not used by the preload.'
);
}
});
if (
!configured.includes('PAGE')
) {
errors.push(
'PAGE must always be configured.'
);
}
Object.entries(
config.fields
).forEach(
([name, field]) => {
if (
!field ||
typeof field !== 'object' ||
Array.isArray(field)
) {
errors.push(
'Field "' +
name +
'" must be an object.'
);
return;
}
if (!field.type) {
warnings.push(
'Field "' +
name +
'" has no type.'
);
}
if (
(
field.type === 'select' ||
field.type === 'multiselect'
) &&
!field.source &&
!Array.isArray(
field.options
)
) {
warnings.push(
'Field "' +
name +
'" is a ' +
field.type +
' without a source or manual options.'
);
}
}
);
return {
valid:
errors.length === 0,
errors: errors,
warnings: warnings
};
},
/* =========================================================
* TRANSFORMATION HELPERS
* ========================================================= */
transformationObject(
transform,
prefix,
suffix,
from,
to
) {
if (!transform) {
return null;
}
switch (transform) {
case 'remove-prefix':
if (!prefix) {
return null;
}
return {
transform:
'remove-prefix',
prefix: prefix
};
case 'remove-suffix':
if (!suffix) {
return null;
}
return {
transform:
'remove-suffix',
suffix: suffix
};
case 'replace':
if (from === '') {
return null;
}
return {
transform: 'replace',
from: from,
to: to || ''
};
default:
return null;
}
},
/* =========================================================
* UI HELPERS
* ========================================================= */
createSection(title, className) {
const section =
$('<section>')
.addClass(
'preload-editor-section'
);
if (className) {
section.addClass(className);
}
$('<h2>')
.addClass(
'preload-editor-section-title'
)
.text(title)
.appendTo(section);
return section;
},
createButton(
label,
className,
callback
) {
const button =
$('<button>')
.attr('type', 'button')
.addClass(
'preload-editor-button'
)
.text(label);
if (className) {
button.addClass(className);
}
if (callback) {
button.on(
'click.preloadEditor',
callback
);
}
return button;
},
fieldRow(
label,
input,
description
) {
const row =
$('<div>')
.addClass(
'preload-editor-field'
);
const labelElement =
$('<label>')
.addClass(
'preload-editor-label'
)
.text(label);
row.append(labelElement);
if (description) {
$('<div>')
.addClass(
'preload-editor-help'
)
.text(description)
.appendTo(row);
}
row.append(input);
return row;
},
textInput(value, placeholder) {
return $('<input>')
.attr({
type: 'text',
value:
value === undefined ||
value === null
? ''
: value,
placeholder:
placeholder || ''
})
.addClass(
'preload-editor-input'
);
},
textarea(value) {
return $('<textarea>')
.val(
value === undefined ||
value === null
? ''
: value
)
.addClass(
'preload-editor-textarea'
);
},
checkbox(checked) {
return $('<input>')
.attr({
type: 'checkbox'
})
.prop(
'checked',
!!checked
)
.addClass(
'preload-editor-checkbox'
);
},
select(options, value) {
const select =
$('<select>')
.addClass(
'preload-editor-select'
);
options.forEach(option => {
$('<option>')
.attr(
'value',
option.value
)
.text(option.label)
.prop(
'selected',
String(
option.value
) ===
String(
value === undefined
? ''
: value
)
)
.appendTo(select);
});
return select;
},
/* =========================================================
* MAIN UI
* ========================================================= */
init() {
const root =
$('#mw-content-text');
if (!root.length) {
return;
}
this.root =
$('<div>')
.attr(
'id',
'preload-editor'
)
.addClass(
'preload-editor'
);
root.empty().append(
this.root
);
this.renderShell();
},
renderShell() {
this.root.empty();
$('<h1>')
.addClass(
'preload-editor-title'
)
.text(
'Petit Planet Wiki: Preload Editor'
)
.appendTo(this.root);
$('<p>')
.addClass(
'preload-editor-intro'
)
.text(
'Create and edit wiki preloads and their configuration from one place.'
)
.appendTo(this.root);
/*
* Loader.
*/
const loader =
this.createSection(
'Preload'
);
const titleInput =
this.textInput(
this.state.preloadTitle,
'Template:Example/Preload'
)
.attr(
'id',
'preload-editor-title-input'
);
const loadButton =
this.createButton(
'Load',
'preload-editor-button-primary',
() =>
this.loadExisting(
titleInput.val()
)
);
const newButton =
this.createButton(
'New',
'',
() =>
this.startNew()
);
const sampleButton =
this.createButton(
'Load Sample Page',
'',
() =>
this.loadSamplePage()
);
const loadRow =
$('<div>')
.addClass(
'preload-editor-toolbar'
);
loadRow.append(
titleInput,
loadButton,
newButton,
sampleButton
);
loader.append(loadRow);
this.root.append(loader);
/*
* Status area.
*/
this.status =
$('<div>')
.addClass(
'preload-editor-status'
);
this.root.append(
this.status
);
/*
* Workspace.
*/
this.workspace =
$('<div>')
.addClass(
'preload-editor-workspace'
);
this.root.append(
this.workspace
);
/*
* Initially show instructions.
*/
this.renderWelcome();
},
renderWelcome() {
this.workspace.empty();
const section =
this.createSection(
'Get started'
);
$('<p>')
.text(
'Load an existing preload, create a new one, or start from a sample wiki page.'
)
.appendTo(section);
const tips =
$('<ul>')
.addClass(
'preload-editor-tips'
);
[
'Existing preload: loads the preload and its config.json if available.',
'New preload: starts with the standard Preload Template structure.',
'Sample page: loads normal wiki page content so you can turn it into a preload.'
].forEach(text => {
$('<li>')
.text(text)
.appendTo(tips);
});
section.append(tips);
this.workspace.append(
section
);
},
setStatus(message, type) {
if (!this.status) {
return;
}
this.status
.removeClass(
'is-error',
'is-success',
'is-warning',
'is-loading'
);
if (type) {
this.status.addClass(
'is-' + type
);
}
this.status.text(
message || ''
);
},
/* =========================================================
* LOAD / CREATE
* ========================================================= */
async loadExisting(title) {
title =
String(title || '').trim();
if (!title) {
mw.notify(
'Enter a preload page title first.',
{
type: 'warn'
}
);
return;
}
this.setStatus(
'Loading preload...',
'loading'
);
try {
const page =
await this.getPage(title);
if (!page) {
const suggestions =
await this.findSimilarPreloads(
title
);
this.renderMissingPage(
title,
suggestions
);
this.setStatus(
'Preload not found.',
'warning'
);
return;
}
await this.loadDefinition(
page.title,
page.content
);
this.setStatus(
'Preload loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor:',
error
);
this.setStatus(
error.message ||
'Could not load preload.',
'error'
);
}
},
async findSimilarPreloads(title) {
let query =
title
.replace(
/\/Preload$/i,
''
)
.trim();
if (!query) {
query = title;
}
try {
const results =
await this.searchPages(
query
);
return results.filter(
result =>
/\/Preload$/i.test(
result.title
)
);
} catch (error) {
console.error(
'Preload Editor search:',
error
);
return [];
}
},
renderMissingPage(
title,
suggestions
) {
this.workspace.empty();
const section =
this.createSection(
'Preload not found'
);
$('<p>')
.text(
'No preload exists at "' +
title +
'".'
)
.appendTo(section);
if (suggestions.length) {
$('<h3>')
.text(
'Similar preloads'
)
.appendTo(section);
const list =
$('<div>')
.addClass(
'preload-editor-suggestions'
);
suggestions.forEach(
result => {
const button =
this.createButton(
result.title,
'preload-editor-suggestion',
() =>
this.loadExisting(
result.title
)
);
list.append(button);
}
);
section.append(list);
}
section.append(
this.createButton(
'Create this preload',
'preload-editor-button-primary',
() =>
this.startNew(title)
)
);
this.workspace.append(
section
);
},
async startNew(title) {
title =
String(
title ||
''
).trim();
this.state = {
preloadTitle: title,
preloadSource: '',
preloadContent:
this.defaults.preload,
config:
this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.renderEditor();
this.setStatus(
'Creating a new preload.',
'success'
);
},
async loadSamplePage() {
const dialog =
this.createPromptDialog(
'Load Sample Page',
'Enter the title of a normal wiki page to use as the starting point.'
);
const result =
await dialog;
if (!result) {
return;
}
this.setStatus(
'Loading sample page...',
'loading'
);
try {
const page =
await this.getPage(
result
);
if (!page) {
throw new Error(
'Sample page not found: ' +
result
);
}
const sample =
this.convertSampleToPreload(
page.content
);
this.state = {
preloadTitle: '',
preloadSource: '',
preloadContent:
sample,
config:
this.emptyConfig(),
variables:
['PAGE'],
conditions: [],
mode: 'new',
dirty: true
};
this.reanalyze();
this.renderEditor();
this.setStatus(
'Sample page loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor sample:',
error
);
this.setStatus(
error.message ||
'Could not load sample page.',
'error'
);
}
},
convertSampleToPreload(source) {
let content =
String(source || '').trim();
/*
* If the sample itself contains includeonly,
* use only its included content.
*
* Otherwise use the complete page.
*/
const include =
content.match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
if (include) {
content =
include[1].trim();
}
/*
* Remove documentation/noinclude blocks
* from a normal sample page.
*/
content =
content.replace(
/<noinclude>[\s\S]*?<\/noinclude>/gi,
''
).trim();
return (
'<includeonly>\n' +
content +
'\n</includeonly>\n' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
);
},
createPromptDialog(title, message) {
return new Promise(resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt'
);
$('<h2>')
.text(title)
.appendTo(box);
$('<p>')
.text(message)
.appendTo(box);
const input =
this.textInput();
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const confirm =
this.createButton(
'Load',
'preload-editor-button-primary',
() => {
const value =
String(
input.val() ||
''
).trim();
if (!value) {
return;
}
overlay.remove();
resolve(value);
}
);
buttons.append(
cancel,
confirm
);
box.append(
input,
buttons
);
overlay.append(box);
$(document.body).append(
overlay
);
input.trigger('focus');
input.on(
'keydown.preloadEditor',
event => {
if (
event.key ===
'Enter'
) {
confirm.trigger(
'click'
);
}
if (
event.key ===
'Escape'
) {
cancel.trigger(
'click'
);
}
}
);
});
},
async loadDefinition(
title,
source
) {
const content =
this.extractIncludeonly(
source
);
let config =
this.emptyConfig();
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
try {
const configPage =
await this.getPage(
configTitle
);
if (configPage) {
try {
config =
JSON.parse(
configPage.content
);
} catch (error) {
throw new Error(
'The existing config.json contains invalid JSON: ' +
error.message
);
}
}
} catch (error) {
/*
* A missing config is allowed.
* Other errors are re-thrown.
*/
if (
error &&
error.message &&
error.message.indexOf(
'invalid JSON'
) !== -1
) {
throw error;
}
}
const analysis =
this.analyzePreload(
content
);
config =
this.prepareConfigForVariables(
config,
analysis
);
this.state = {
preloadTitle: title,
preloadSource: source,
preloadContent: content,
config: config,
variables:
analysis.variables,
conditions:
analysis.conditions,
mode: 'edit',
dirty: false
};
this.renderEditor();
},
/* =========================================================
* EDITOR
* ========================================================= */
renderEditor() {
this.workspace.empty();
const editor =
this.createSection(
'Preload editor',
'preload-editor-main-section'
);
const meta =
$('<div>')
.addClass(
'preload-editor-meta'
);
const title =
this.state.preloadTitle ||
'New preload';
$('<strong>')
.text(title)
.appendTo(meta);
if (
this.state.mode === 'new'
) {
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('NEW')
.appendTo(meta);
}
editor.append(meta);
const textarea =
this.textarea(
this.state.preloadContent
)
.attr(
'id',
'preload-editor-content'
)
.addClass(
'preload-editor-content'
);
textarea.on(
'input.preloadEditor',
() => {
this.state.preloadContent =
textarea.val();
this.state.dirty =
true;
this.reanalyze();
this.renderVariables(
variableContainer
);
this.renderAnalysis(
analysisContainer
);
this.renderConfigPreview(
configPreview
);
}
);
editor.append(
this.fieldRow(
'Preload wikitext',
textarea,
'This is the actual preload. Variables use %%VARIABLE%% syntax.'
)
);
/*
* Analysis.
*/
const analysisSection =
this.createSection(
'Analysis'
);
const analysisContainer =
$('<div>')
.addClass(
'preload-editor-analysis'
);
analysisSection.append(
analysisContainer
);
/*
* Variables.
*/
const variablesSection =
this.createSection(
'Variables'
);
const variableContainer =
$('<div>')
.addClass(
'preload-editor-variables'
);
variablesSection.append(
variableContainer
);
/*
* Config preview.
*/
const configSection =
this.createSection(
'Generated configuration'
);
const configPreview =
$('<pre>')
.addClass(
'preload-editor-config-preview'
);
configSection.append(
configPreview
);
/*
* Actions.
*/
const actions =
$('<div>')
.addClass(
'preload-editor-actions'
);
const save =
this.createButton(
'Save Preload + Config',
'preload-editor-button-primary',
() =>
this.saveAll()
);
const savePreload =
this.createButton(
'Save Preload Only',
'',
() =>
this.savePreloadOnly()
);
actions.append(
save,
savePreload
);
this.workspace.append(
editor,
analysisSection,
variablesSection,
configSection,
actions
);
this.reanalyze();
this.renderVariables(
variableContainer
);
this.renderAnalysis(
analysisContainer
);
this.renderConfigPreview(
configPreview
);
},
reanalyze() {
const analysis =
this.analyzePreload(
this.state.preloadContent
);
this.state.variables =
analysis.variables;
this.state.conditions =
analysis.conditions;
this.state.config =
this.prepareConfigForVariables(
this.state.config ||
this.emptyConfig(),
analysis
);
this.analysis =
analysis;
},
renderAnalysis(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.state.preloadContent
);
const variableCount =
analysis.variables.length;
const conditionCount =
analysis.conditions.length;
const summary =
$('<div>')
.addClass(
'preload-editor-analysis-summary'
);
summary.append(
$('<span>')
.text(
variableCount +
' variable' +
(
variableCount === 1
? ''
: 's'
)
)
);
summary.append(
$('<span>')
.text(
conditionCount +
' condition marker' +
(
conditionCount === 1
? ''
: 's'
)
)
);
container.append(summary);
if (
analysis.conditionVariables.length
) {
const note =
$('<div>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'Condition variables: ' +
analysis.conditionVariables.join(
', '
)
);
container.append(note);
}
const validation =
this.validateConfig(
this.state.config,
analysis
);
if (
validation.errors.length
) {
const errors =
$('<ul>')
.addClass(
'preload-editor-validation-errors'
);
validation.errors.forEach(
error => {
$('<li>')
.text(error)
.appendTo(errors);
}
);
container.append(errors);
}
if (
validation.warnings.length
) {
const warnings =
$('<ul>')
.addClass(
'preload-editor-validation-warnings'
);
validation.warnings.forEach(
warning => {
$('<li>')
.text(warning)
.appendTo(warnings);
}
);
container.append(warnings);
}
if (
!validation.errors.length &&
!validation.warnings.length
) {
container.append(
$('<div>')
.addClass(
'preload-editor-validation-success'
)
.text(
'Preload and configuration are in sync.'
)
);
}
},
/* =========================================================
* VARIABLE CARDS
* ========================================================= */
renderVariables(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.state.preloadContent
);
const variables =
analysis.variables.slice();
/*
* PAGE always comes first.
*/
const ordered = [];
ordered.push('PAGE');
variables.forEach(name => {
if (
name !== 'PAGE' &&
!ordered.includes(name)
) {
ordered.push(name);
}
});
/*
* Preserve unused config fields after
* actual variables.
*/
Object.keys(
this.state.config.fields || {}
).forEach(name => {
const upper =
name.toUpperCase();
if (
!ordered.includes(upper)
) {
ordered.push(upper);
}
});
ordered.forEach(name => {
const field =
this.state.config.fields[name] ||
this.createDefaultField(
name,
analysis
);
this.state.config.fields[name] =
field;
const card =
this.createVariableCard(
name,
field,
analysis
);
container.append(card);
});
},
createVariableCard(
name,
field,
analysis
) {
const card =
$('<div>')
.addClass(
'preload-editor-variable'
)
.attr(
'data-variable',
name
);
const header =
$('<div>')
.addClass(
'preload-editor-variable-header'
);
const heading =
$('<h3>')
.text(name);
header.append(heading);
if (name === 'PAGE') {
header.append(
$('<span>')
.addClass(
'preload-editor-badge'
)
.text(
'AUTOMATIC'
)
);
} else if (
!analysis.variables.includes(
name
)
) {
header.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text(
'UNUSED'
)
);
}
card.append(header);
/*
* Existing field object is mutated by
* the controls below.
*/
const label =
this.textInput(
field.label || ''
);
label.on(
'input.preloadEditor',
() => {
field.label =
label.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Label',
label
)
);
const description =
this.textarea(
field.description || ''
);
description
.addClass(
'preload-editor-small-textarea'
)
.on(
'input.preloadEditor',
() => {
field.description =
description.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Description',
description
)
);
const types = [
['text', 'Text'],
['textarea', 'Textarea'],
['number', 'Number'],
['boolean', 'Boolean'],
['select', 'Select'],
['multiselect', 'Multi-select'],
['page', 'Page'],
['user', 'User'],
['date', 'Date']
];
const typeSelect =
this.select(
types.map(
([value, label]) => ({
value: value,
label: label
})
),
field.type || 'text'
);
typeSelect.on(
'change.preloadEditor',
() => {
field.type =
typeSelect.val();
this.markDirty();
/*
* Re-render the whole card so
* type-specific controls appear.
*/
const newCard =
this.createVariableCard(
name,
field,
analysis
);
card.replaceWith(
newCard
);
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Type',
typeSelect
)
);
/*
* PAGE has special controls.
*/
if (name === 'PAGE') {
field.type = 'page';
const required =
this.checkbox(
field.required !== false
);
required.on(
'change.preloadEditor',
() => {
field.required =
required.prop(
'checked'
);
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
const namespace =
this.select(
[
{
value: 'Main',
label: 'Main'
},
{
value: 'Template',
label: 'Template'
},
{
value: 'File',
label: 'File'
},
{
value: 'Category',
label: 'Category'
},
{
value: 'User',
label: 'User'
}
],
field.namespace ||
'Main'
);
namespace.on(
'change.preloadEditor',
() => {
field.namespace =
namespace.val();
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Namespace',
namespace
)
);
const allowNew =
this.checkbox(
field['allow-new'] ===
true
);
allowNew.on(
'change.preloadEditor',
() => {
field['allow-new'] =
allowNew.prop(
'checked'
);
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Allow new page',
allowNew
)
);
return card;
}
/*
* Required.
*/
const required =
this.checkbox(
field.required === true
);
required.on(
'change.preloadEditor',
() => {
field.required =
required.prop(
'checked'
);
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
/*
* Placeholder.
*/
const placeholder =
this.textInput(
field.placeholder || ''
);
placeholder.on(
'input.preloadEditor',
() => {
const value =
placeholder.val();
if (value) {
field.placeholder =
value;
} else {
delete field.placeholder;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Placeholder',
placeholder
)
);
/*
* Default.
*/
const defaultInput =
this.textInput(
field.default ===
undefined
? ''
: field.default
);
defaultInput.on(
'input.preloadEditor',
() => {
const value =
defaultInput.val();
if (value) {
field.default =
value;
} else {
delete field.default;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Default',
defaultInput,
'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
)
);
/*
* Textarea-specific maxRows.
*/
if (
field.type === 'textarea'
) {
const maxRows =
this.textInput(
field.maxRows ||
''
)
.attr(
'type',
'number'
);
maxRows.on(
'input.preloadEditor',
() => {
const value =
parseInt(
maxRows.val(),
10
);
if (
Number.isFinite(
value
) &&
value > 0
) {
field.maxRows =
value;
} else {
delete field.maxRows;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Maximum rows',
maxRows
)
);
}
/*
* Select/multiselect source.
*/
if (
field.type === 'select' ||
field.type === 'multiselect'
) {
this.renderSelectConfiguration(
card,
field
);
}
return card;
},
renderSelectConfiguration(
card,
field
) {
const source =
this.textInput(
field.source || '',
'category:Example'
);
source.on(
'input.preloadEditor',
() => {
const value =
source.val();
if (value) {
field.source =
value;
} else {
delete field.source;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source',
source,
'Currently supported source format: category:Category Name'
)
);
const sourcePrefix =
this.textInput(
field['source-prefix'] ||
''
);
sourcePrefix.on(
'input.preloadEditor',
() => {
const value =
sourcePrefix.val();
if (value) {
field['source-prefix'] =
value;
} else {
delete field[
'source-prefix'
];
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source prefix',
sourcePrefix,
'Only source values beginning with this text are included.'
)
);
const excludeCategory =
this.textInput(
field.exclude &&
field.exclude.category
? field.exclude.category
: ''
);
excludeCategory.on(
'input.preloadEditor',
() => {
const value =
excludeCategory.val();
if (value) {
field.exclude =
Object.assign(
{},
field.exclude || {},
{
category:
value
}
);
} else if (
field.exclude
) {
delete field.exclude.category;
if (
!Object.keys(
field.exclude
).length
) {
delete field.exclude;
}
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Exclude category',
excludeCategory
)
);
/*
* Value transformation.
*/
card.append(
this.createTransformControls(
field,
'value',
'Value transformation'
)
);
/*
* Label transformation.
*/
card.append(
this.createTransformControls(
field,
'label',
'Label transformation'
)
);
/*
* Manual options.
*/
const optionsValue =
Array.isArray(
field.options
)
? field.options
.map(option => {
if (
typeof option ===
'string'
) {
return option;
}
return (
String(
option.value ||
''
) +
' | ' +
String(
option.label ||
option.value ||
''
)
);
})
.join('\n')
: '';
const options =
this.textarea(
optionsValue
)
.addClass(
'preload-editor-small-textarea'
);
options.on(
'input.preloadEditor',
() => {
const lines =
options.val()
.split('\n')
.map(
line =>
line.trim()
)
.filter(Boolean);
if (!lines.length) {
delete field.options;
} else {
field.options =
lines.map(line => {
const parts =
line.split(
/\s*\|\s*/
);
if (
parts.length ===
1
) {
return parts[0];
}
return {
value:
parts[0],
label:
parts
.slice(1)
.join(
' | '
)
};
});
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Manual options',
options,
'One per line. Optional label format: value | label. Leave empty when using a source.'
)
);
},
createTransformControls(
field,
property,
title
) {
const wrapper =
$('<div>')
.addClass(
'preload-editor-transform'
);
$('<h4>')
.text(title)
.appendTo(wrapper);
const existing =
field[property] &&
typeof field[property] ===
'object'
? field[property]
: {};
const transform =
this.select(
[
{
value: '',
label: 'No transformation'
},
{
value: 'remove-prefix',
label: 'Remove prefix'
},
{
value: 'remove-suffix',
label: 'Remove suffix'
},
{
value: 'replace',
label: 'Replace'
}
],
existing.transform || ''
);
const prefix =
this.textInput(
existing.prefix || ''
);
const suffix =
this.textInput(
existing.suffix || ''
);
const from =
this.textInput(
existing.from || ''
);
const to =
this.textInput(
existing.to || ''
);
const update =
() => {
const type =
transform.val();
let object = null;
if (
type ===
'remove-prefix'
) {
if (
prefix.val()
) {
object = {
transform:
type,
prefix:
prefix.val()
};
}
} else if (
type ===
'remove-suffix'
) {
if (
suffix.val()
) {
object = {
transform:
type,
suffix:
suffix.val()
};
}
} else if (
type === 'replace'
) {
if (
from.val()
) {
object = {
transform:
type,
from:
from.val(),
to:
to.val()
};
}
}
if (object) {
field[property] =
object;
} else {
delete field[property];
}
this.markDirty();
this.renderConfigPreviewBySelector();
};
transform.on(
'change.preloadEditor',
() => {
update();
renderInputs();
}
);
[
prefix,
suffix,
from,
to
].forEach(input => {
input.on(
'input.preloadEditor',
update
);
});
const controls =
$('<div>')
.addClass(
'preload-editor-transform-controls'
);
controls.append(
this.fieldRow(
'Transformation',
transform
)
);
const prefixRow =
this.fieldRow(
'Prefix',
prefix
);
const suffixRow =
this.fieldRow(
'Suffix',
suffix
);
const fromRow =
this.fieldRow(
'From',
from
);
const toRow =
this.fieldRow(
'To',
to
);
controls.append(
prefixRow,
suffixRow,
fromRow,
toRow
);
const renderInputs =
() => {
const type =
transform.val();
prefixRow.toggle(
type ===
'remove-prefix'
);
suffixRow.toggle(
type ===
'remove-suffix'
);
fromRow.toggle(
type === 'replace'
);
toRow.toggle(
type === 'replace'
);
};
renderInputs();
wrapper.append(
controls
);
return wrapper;
},
/* =========================================================
* CONFIG OUTPUT
* ========================================================= */
getCleanConfig() {
const config =
JSON.parse(
JSON.stringify(
this.state.config
)
);
/*
* Keep PAGE first.
*/
const fields =
config.fields || {};
const orderedFields = {};
if (fields.PAGE) {
orderedFields.PAGE =
fields.PAGE;
}
Object.keys(fields)
.filter(
name =>
name.toUpperCase() !==
'PAGE'
)
.forEach(name => {
orderedFields[name] =
fields[name];
});
config.fields =
orderedFields;
return config;
},
getConfigJSON() {
return JSON.stringify(
this.getCleanConfig(),
null,
4
);
},
renderConfigPreview(container) {
if (!container) {
return;
}
container.text(
this.getConfigJSON()
);
},
renderConfigPreviewBySelector() {
const preview =
this.root &&
this.root.find(
'.preload-editor-config-preview'
);
if (preview && preview.length) {
this.renderConfigPreview(
preview
);
}
const analysis =
this.root &&
this.root.find(
'.preload-editor-analysis'
);
if (
analysis &&
analysis.length
) {
this.renderAnalysis(
analysis
);
}
},
markDirty() {
this.state.dirty = true;
},
/* =========================================================
* SAVE
* ========================================================= */
async savePreloadOnly() {
let title =
String(
this.state.preloadTitle ||
''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
const validation =
this.validateBeforeSave();
if (
validation.errors.length
) {
this.showValidationErrors(
validation
);
return;
}
this.setStatus(
'Saving preload...',
'loading'
);
try {
const exists =
await this.pageExists(
title
);
await this.savePage(
title,
this.state.preloadContent,
exists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!exists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.setStatus(
'Preload saved successfully.',
'success'
);
mw.notify(
'Preload saved.',
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload.',
'error'
);
mw.notify(
error.message ||
'Could not save preload.',
{
type: 'error'
}
);
}
},
async saveAll() {
let title =
String(
this.state.preloadTitle ||
''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
const validation =
this.validateBeforeSave();
if (
validation.errors.length
) {
this.showValidationErrors(
validation
);
return;
}
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
this.setStatus(
'Saving preload and config...',
'loading'
);
try {
const preloadExists =
await this.pageExists(
title
);
await this.savePage(
title,
this.state.preloadContent,
preloadExists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!preloadExists
);
const configExists =
await this.pageExists(
configTitle
);
await this.savePage(
configTitle,
this.getConfigJSON(),
configExists
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor',
!configExists
);
this.state.mode = 'edit';
this.state.dirty = false;
this.state.preloadTitle =
title;
this.setStatus(
'Preload and config saved successfully.',
'success'
);
mw.notify(
'Preload and config saved.',
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.setStatus(
error.message ||
'Could not save preload/config.',
'error'
);
mw.notify(
error.message ||
'Could not save preload/config.',
{
type: 'error'
}
);
}
},
validateBeforeSave() {
const errors = [];
const warnings = [];
const title =
String(
this.state.preloadTitle ||
''
).trim();
if (!title) {
errors.push(
'A preload title is required.'
);
}
if (
title &&
!/\/Preload$/i.test(title)
) {
warnings.push(
'The title does not end with "/Preload".'
);
}
const content =
String(
this.state.preloadContent ||
''
);
if (!content.trim()) {
errors.push(
'The preload cannot be empty.'
);
}
if (
!/<includeonly>/i.test(
content
)
) {
warnings.push(
'The preload does not contain an <includeonly> section.'
);
}
if (
!/<noinclude>/i.test(
content
)
) {
warnings.push(
'The preload does not contain a <noinclude> section.'
);
}
const analysis =
this.analyzePreload(
content
);
const configValidation =
this.validateConfig(
this.state.config,
analysis
);
errors.push(
...configValidation.errors
);
warnings.push(
...configValidation.warnings
);
/*
* Basic conditional marker validation.
*/
let depth = 0;
analysis.conditions.forEach(
condition => {
if (
condition.type ===
'if'
) {
depth++;
} else if (
condition.type ===
'endif'
) {
depth--;
if (depth < 0) {
errors.push(
'Found @endif without a matching @if.'
);
depth = 0;
}
} else if (
condition.type ===
'elseif' ||
condition.type ===
'else'
) {
if (depth === 0) {
errors.push(
'@' +
condition.type +
' exists outside an @if block.'
);
}
}
}
);
if (depth > 0) {
errors.push(
'One or more @if blocks are missing @endif.'
);
}
return {
errors: errors,
warnings: warnings,
valid:
errors.length === 0
};
},
showValidationErrors(validation) {
this.renderAnalysis(
this.root.find(
'.preload-editor-analysis'
)
);
this.setStatus(
validation.errors.join(
' '
),
'error'
);
mw.notify(
validation.errors.join(
'\n'
),
{
type: 'error'
}
);
}
};
/*
* Expose globally.
*/
mw.PreloadEditor =
PreloadEditor;
/*
* Initialise only on the project page.
*/
$(function () {
const title =
mw.config.get(
'wgPageName'
);
if (
title !==
'Petit_Planet:Preload_Editor'
) {
return;
}
PreloadEditor.init();
});
}(mediaWiki, jQuery));