MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
MediaWiki interface page
More actions
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| Line 1: | Line 1: | ||
(function (mw, $) { | (function (mw, $) { | ||
'use strict'; | 'use strict'; | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: '0. | version: '0.3.0', | ||
state: { | state: { | ||
| Line 27: | Line 26: | ||
defaults: { | defaults: { | ||
preload: | preload: | ||
'<includeonly>\n | '<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n' | ||
}, | }, | ||
| Line 36: | Line 32: | ||
async getPage(title) { | async getPage(title) { | ||
const | const response = await new mw.Api().get({ | ||
action: 'query', | |||
prop: 'revisions', | |||
titles: title, | |||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: 2 | |||
}); | |||
if (!page || page.missing) | const page = response?.query?.pages?.[0]; | ||
if (!page || page.missing) return null; | |||
const content = | const content = | ||
page.revisions | page.revisions?.[0]?.slots?.main?.content; | ||
return { | return { | ||
title: page.title || title, | title: page.title || title, | ||
content: | content: typeof content === 'string' ? content : '' | ||
}; | }; | ||
}, | }, | ||
| Line 79: | Line 58: | ||
async searchPages(query) { | async searchPages(query) { | ||
const | const response = await new mw.Api().get({ | ||
action: 'query', | |||
list: 'search', | |||
srsearch: query, | |||
srlimit: 10, | |||
srnamespace: 10, | |||
formatversion: 2 | |||
}); | |||
return | return response?.query?.search || []; | ||
}, | }, | ||
async savePage( | async savePage(title, content, summary, createOnly) { | ||
const params = { | const params = { | ||
action: 'edit', | action: 'edit', | ||
title, | |||
text: content, | text: content, | ||
summary: | summary: summary || 'Updated with Preload Editor', | ||
formatversion: 2 | formatversion: 2 | ||
}; | }; | ||
if (createOnly) | if (createOnly) params.createonly = true; | ||
return | return new mw.Api().postWithToken('csrf', params); | ||
}, | }, | ||
| Line 131: | Line 87: | ||
extractIncludeonly(source) { | extractIncludeonly(source) { | ||
const match = | const match = String(source || '').match( | ||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | |||
return match ? match[1].trim() : String(source || '').trim(); | |||
return match | |||
}, | }, | ||
findVariables(content) { | findVariables(content) { | ||
const variables = []; | const variables = []; | ||
const pattern = | const pattern = /%%([A-Za-z0-9_-]+)%%/g; | ||
let match; | let match; | ||
while ( | while ((match = pattern.exec(content)) !== null) { | ||
const name = match[1].toUpperCase(); | |||
if (!variables.includes(name)) variables.push(name); | |||
const name = | |||
if (!variables.includes(name)) | |||
} | } | ||
| Line 166: | Line 110: | ||
const pattern = | const pattern = | ||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | /<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | ||
let match; | let match; | ||
while ( | while ((match = pattern.exec(content)) !== null) { | ||
conditions.push({ | conditions.push({ | ||
type: | type: match[1].toLowerCase(), | ||
expression: match[2].trim(), | |||
expression: | index: match.index | ||
index: | |||
}); | }); | ||
} | } | ||
| Line 187: | Line 125: | ||
findConditionVariables(content) { | findConditionVariables(content) { | ||
const variables = []; | const variables = []; | ||
this.findConditions(content).forEach(condition => { | |||
if (condition.type !== 'if' && | |||
condition.type !== 'elseif') return; | |||
if ( | |||
condition.type !== 'elseif' | |||
const matches = | const matches = | ||
condition.expression.match( | condition.expression.match(/[A-Za-z0-9_-]+/g) || []; | ||
matches.forEach(name => { | matches.forEach(name => { | ||
name = name.toUpperCase(); | |||
if (!variables.includes(name)) variables.push(name); | |||
if ( | |||
}); | }); | ||
}); | }); | ||
| Line 219: | Line 143: | ||
analyzePreload(content) { | analyzePreload(content) { | ||
return { | return { | ||
variables, | variables: this.findVariables(content), | ||
conditions, | conditions: this.findConditions(content), | ||
conditionVariables | conditionVariables: this.findConditionVariables(content) | ||
}; | }; | ||
}, | }, | ||
| Line 241: | Line 154: | ||
emptyConfig() { | emptyConfig() { | ||
return { | return { | ||
settings: { | settings: { incomplete: 'add-stub' }, | ||
fields: { | fields: { | ||
PAGE: { | PAGE: { | ||
label: 'Page Name', | label: 'Page Name', | ||
description: | description: 'Name of the page.', | ||
type: 'page', | type: 'page', | ||
required: true, | required: true, | ||
| Line 259: | Line 169: | ||
normalizeConfig(config) { | normalizeConfig(config) { | ||
if ( | if (!config || | ||
typeof config !== 'object' || | typeof config !== 'object' || | ||
Array.isArray(config) | Array.isArray(config)) { | ||
config = this.emptyConfig(); | config = this.emptyConfig(); | ||
} | } | ||
if ( | if (!config.settings || | ||
typeof config.settings !== 'object' || | typeof config.settings !== 'object' || | ||
Array.isArray(config.settings) | Array.isArray(config.settings)) { | ||
config.settings = {}; | config.settings = {}; | ||
} | } | ||
if ( | if (!config.fields || | ||
typeof config.fields !== 'object' || | typeof config.fields !== 'object' || | ||
Array.isArray(config.fields) | Array.isArray(config.fields)) { | ||
config.fields = {}; | config.fields = {}; | ||
} | } | ||
| Line 286: | Line 190: | ||
config.fields.PAGE = { | config.fields.PAGE = { | ||
label: 'Page Name', | label: 'Page Name', | ||
description: | description: 'Name of the page.', | ||
type: 'page', | type: 'page', | ||
required: true, | required: true, | ||
| Line 299: | Line 202: | ||
createDefaultField(name, analysis) { | createDefaultField(name, analysis) { | ||
return { | return { | ||
label: | label: this.humanizeName(name), | ||
description: '', | description: '', | ||
type: | type: analysis.conditionVariables.includes(name) | ||
? 'boolean' | |||
: 'text' | |||
}; | }; | ||
}, | }, | ||
| Line 318: | Line 214: | ||
return String(name || '') | return String(name || '') | ||
.replace(/[_-]+/g, ' ') | .replace(/[_-]+/g, ' ') | ||
.replace( | .replace(/([a-z])([A-Z])/g, '$1 $2') | ||
.replace(/\s+/g, ' ') | .replace(/\s+/g, ' ') | ||
.trim() | .trim() | ||
.replace( | .replace(/\b\w/g, char => char.toUpperCase()); | ||
}, | }, | ||
prepareConfigForVariables( | prepareConfigForVariables(config, analysis) { | ||
config = this.normalizeConfig(config); | |||
config = | |||
const existing = | const existing = config.fields || {}; | ||
const fields = {}; | |||
fields.PAGE = existing.PAGE || { | |||
label: 'Page Name', | |||
fields.PAGE = | description: 'Name of the page.', | ||
type: 'page', | |||
required: true, | |||
namespace: 'Main', | |||
'allow-new': true | |||
}; | |||
analysis.variables.forEach(name => { | |||
if (name === 'PAGE') return; | |||
fields[name] = | fields[name] = | ||
existing[name] || | existing[name] || | ||
this.createDefaultField( | this.createDefaultField(name, analysis); | ||
}); | }); | ||
Object.keys(existing).forEach( | Object.keys(existing).forEach(name => { | ||
const upper = name.toUpperCase(); | |||
if (!fields[upper]) fields[upper] = existing[name]; | |||
}); | |||
config.fields = fields; | config.fields = fields; | ||
| Line 386: | Line 255: | ||
const warnings = []; | const warnings = []; | ||
if ( | if (!config || | ||
typeof config !== 'object' || | typeof config !== 'object' || | ||
Array.isArray(config) | Array.isArray(config)) { | ||
return { | return { | ||
valid: false, | valid: false, | ||
errors: [ | errors: ['Config must be an object.'], | ||
warnings: [] | warnings: [] | ||
}; | }; | ||
} | } | ||
if ( | if (!config.fields || | ||
typeof config.fields !== 'object' || | typeof config.fields !== 'object' || | ||
Array.isArray(config.fields) | Array.isArray(config.fields)) { | ||
return { | return { | ||
valid: false, | valid: false, | ||
errors, | errors: ['"fields" must be an object.'], | ||
warnings | warnings | ||
}; | }; | ||
| Line 417: | Line 276: | ||
const configured = | const configured = | ||
Object.keys( | Object.keys(config.fields).map(name => name.toUpperCase()); | ||
analysis.variables.forEach( | analysis.variables.forEach(name => { | ||
if (!configured.includes(name)) { | |||
errors.push( | |||
'Variable "' + name + '" is not configured.' | |||
); | |||
} | } | ||
); | }); | ||
configured.forEach(name => { | configured.forEach(name => { | ||
if ( | if (!analysis.variables.includes(name)) { | ||
warnings.push( | warnings.push( | ||
'Field "' + | 'Field "' + name + '" is not used by the preload.' | ||
); | ); | ||
} | } | ||
| Line 452: | Line 295: | ||
if (!configured.includes('PAGE')) { | if (!configured.includes('PAGE')) { | ||
errors.push( | errors.push('PAGE must always be configured.'); | ||
} | } | ||
Object.entries( | 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 { | return { | ||
valid: | valid: !errors.length, | ||
errors, | errors, | ||
warnings | warnings | ||
| Line 513: | Line 335: | ||
// Transformation helpers | // Transformation helpers | ||
transformationObject( | transformationObject(transform, prefix, suffix, from, to) { | ||
if (!transform) return null; | |||
if (!transform) | |||
switch (transform) { | switch (transform) { | ||
case 'remove-prefix': | case 'remove-prefix': | ||
return prefix | return prefix | ||
? { | ? { transform, prefix } | ||
: null; | : null; | ||
case 'remove-suffix': | case 'remove-suffix': | ||
return suffix | return suffix | ||
? { | ? { transform, suffix } | ||
: null; | : null; | ||
| Line 546: | Line 352: | ||
return from === '' | return from === '' | ||
? null | ? null | ||
: { | : { transform, from, to: to || '' }; | ||
default: | default: | ||
| Line 560: | Line 361: | ||
// UI helpers | // UI helpers | ||
createSection( | createSection(title, className) { | ||
const section = $('<section>') | |||
.addClass('preload-editor-section'); | |||
const section = | |||
if (className) | if (className) section.addClass(className); | ||
$('<h2>') | $('<h2>') | ||
.addClass( | .addClass('preload-editor-section-title') | ||
.text(title) | .text(title) | ||
.appendTo(section); | .appendTo(section); | ||
| Line 584: | Line 375: | ||
}, | }, | ||
createButton( | createButton(label, className, callback) { | ||
const button = $('<button>') | |||
.attr('type', 'button') | |||
.addClass('preload-editor-button') | |||
.text(label); | |||
const button = | |||
if (className) button.addClass(className); | |||
if (callback) { | if (callback) { | ||
button.on( | button.on('click.preloadEditor', callback); | ||
} | } | ||
| Line 614: | Line 389: | ||
}, | }, | ||
fieldRow( | fieldRow(label, input, description) { | ||
const row = $('<div>') | |||
.addClass('preload-editor-field'); | |||
const row = | |||
$('<label>') | $('<label>') | ||
.addClass( | .addClass('preload-editor-label') | ||
.text(label) | .text(label) | ||
.appendTo(row); | .appendTo(row); | ||
| Line 634: | Line 400: | ||
if (description) { | if (description) { | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass('preload-editor-help') | ||
.text(description) | .text(description) | ||
.appendTo(row); | .appendTo(row); | ||
| Line 645: | Line 409: | ||
}, | }, | ||
textInput( | textInput(value, placeholder) { | ||
return $('<input>') | return $('<input>') | ||
.attr({ | .attr({ | ||
type: 'text', | type: 'text', | ||
value: | value: value == null ? '' : value, | ||
placeholder: placeholder || '' | |||
placeholder: | |||
}) | }) | ||
.addClass( | .addClass('preload-editor-input'); | ||
}, | }, | ||
textarea(value) { | textarea(value) { | ||
return $('<textarea>') | return $('<textarea>') | ||
.val( | .val(value == null ? '' : value) | ||
.addClass('preload-editor-textarea'); | |||
.addClass( | |||
}, | }, | ||
checkbox(checked) { | checkbox(checked) { | ||
return $('<input>') | return $('<input>') | ||
.attr( | .attr('type', 'checkbox') | ||
.prop('checked', !!checked) | |||
.addClass('preload-editor-checkbox'); | |||
.prop( | |||
.addClass( | |||
}, | }, | ||
select(options, value) { | select(options, value) { | ||
const select = | const select = $('<select>') | ||
.addClass('preload-editor-select'); | |||
options.forEach(option => { | options.forEach(option => { | ||
$('<option>') | $('<option>') | ||
.attr( | .attr('value', option.value) | ||
.text(option.label) | .text(option.label) | ||
.prop( | .prop( | ||
'selected', | 'selected', | ||
String( | String(option.value) === String(value ?? '') | ||
) | ) | ||
.appendTo(select); | .appendTo(select); | ||
| Line 733: | Line 459: | ||
} | } | ||
const { | const { view, inputHandler } = this.editor; | ||
if (view && inputHandler) { | if (view && inputHandler) { | ||
view.dom.removeEventListener( | view.dom.removeEventListener('input', inputHandler); | ||
} | } | ||
if (view) | if (view) view.destroy(); | ||
this.editor = null; | this.editor = null; | ||
| Line 759: | Line 477: | ||
getEditorContent() { | getEditorContent() { | ||
if ( | if (this.editorView?.state) { | ||
return this.editorView.state.doc.toString(); | |||
return this.editorView.state.doc | |||
} | } | ||
return | return this.state.preloadContent || ''; | ||
}, | }, | ||
setEditorContent(content) { | setEditorContent(content) { | ||
content = | content = String(content || ''); | ||
if (!this.editorView) { | if (!this.editorView) { | ||
this.state.preloadContent = | this.state.preloadContent = content; | ||
return; | return; | ||
} | } | ||
const current = | const current = this.editorView.state.doc.toString(); | ||
if (current === content) return; | |||
if (current === content) | |||
this.editorView.dispatch({ | this.editorView.dispatch({ | ||
changes: { | changes: { | ||
from: 0, | from: 0, | ||
to: | to: this.editorView.state.doc.length, | ||
insert: content | insert: content | ||
} | } | ||
| Line 802: | Line 505: | ||
getEditorSelection() { | getEditorSelection() { | ||
if ( | if (!this.editorView?.state) return null; | ||
const selection = | const selection = this.editorView.state.selection.main; | ||
return { | return { | ||
from: selection.from, | from: selection.from, | ||
to: selection.to, | to: selection.to, | ||
text: | text: this.editorView.state.doc.sliceString( | ||
selection.from, | |||
selection.to | |||
) | |||
}; | }; | ||
}, | }, | ||
focusEditor() { | focusEditor() { | ||
if (this.editorView) | if (this.editorView) this.editorView.focus(); | ||
}, | }, | ||
| Line 837: | Line 529: | ||
cursorPosition | cursorPosition | ||
) { | ) { | ||
if (!this.editorView) | if (!this.editorView) return; | ||
const view = this.editorView; | const view = this.editorView; | ||
const selection = | const selection = view.state.selection.main; | ||
const from = | const from = | ||
| Line 861: | Line 550: | ||
view.dispatch({ | view.dispatch({ | ||
changes: { | changes: { from, to, insert: text }, | ||
selection: { anchor: cursor } | |||
selection: { | |||
}); | }); | ||
| Line 874: | Line 557: | ||
}, | }, | ||
// V2.3 editor tools | |||
wrapSelectionWithVariable() { | |||
const selected = this.getEditorSelection(); | |||
if (! | if (!selected || selected.from === selected.to) { | ||
this.focusEditor(); | |||
return; | return; | ||
} | } | ||
const | const replacement = | ||
'%%' + selected.text + '%%'; | |||
this.insertEditorText( | |||
replacement, | |||
selected.from, | |||
selected.to, | |||
selected.from + replacement.length | |||
); | |||
}, | |||
insertVariable(name) { | |||
name = String(name || '').trim().toUpperCase(); | |||
if (!name) return; | |||
const selected = this.getEditorSelection(); | |||
if (!selected) return; | |||
const text = | const text = '%%' + name + '%%'; | ||
this.insertEditorText( | this.insertEditorText( | ||
| Line 901: | Line 594: | ||
}, | }, | ||
insertCondition(type, variable) { | |||
type = String(type || '').toLowerCase(); | |||
variable = String(variable || '').trim().toUpperCase(); | |||
const selected = this.getEditorSelection(); | |||
if (!selected) return; | |||
let text; | |||
let cursorOffset; | |||
switch (type) { | |||
case 'if': { | |||
const opening = '<!-- @if ' + variable + ' -->'; | |||
const closing = '<!-- @endif -->'; | |||
if (selected.from !== selected.to) { | |||
text = | |||
opening + | |||
'\n' + | |||
selected.text + | |||
'\n' + | |||
closing; | |||
cursorOffset = text.length; | |||
} else { | |||
text = | |||
opening + | |||
'\n\n' + | |||
closing; | |||
cursorOffset = opening.length + 1; | |||
} | |||
break; | |||
} | |||
case 'elseif': | |||
text = '<!-- @elseif -->'; | |||
cursorOffset = text.length; | |||
break; | |||
case 'else': | |||
text = '<!-- @else -->'; | |||
cursorOffset = text.length; | |||
break; | |||
case 'endif': | |||
text = '<!-- @endif -->'; | |||
cursorOffset = text.length; | |||
break; | |||
default: | |||
return; | |||
} | |||
this.insertEditorText( | this.insertEditorText( | ||
text, | |||
selected.from, | selected.from, | ||
selected.to, | selected.to, | ||
selected.from + | selected.from + cursorOffset | ||
); | ); | ||
}, | }, | ||
getToolVariables() { | |||
const variables = new Set(['PAGE']); | |||
if (this.analysis?.variables) { | |||
this.analysis.variables.forEach(name => { | |||
. | variables.add(String(name).toUpperCase()); | ||
}); | |||
} | } | ||
return Array.from(variables); | |||
}, | |||
createToolSelect(options, className) { | |||
const select = $('<select>') | |||
.addClass('preload-editor-tool-select'); | |||
if (className) select.addClass(className); | |||
options.forEach(option => { | |||
$('<option>') | |||
' | .attr('value', option.value) | ||
.text(option.label) | |||
.appendTo(select); | |||
}); | |||
return select; | |||
}, | |||
updateEditorToolVariables() { | |||
if (!this.editorTools) return; | |||
const variables = this.getToolVariables(); | |||
const current = | |||
this.editorTools.variableSelect.val(); | |||
const currentCondition = | |||
this.editorTools.conditionVariableSelect.val(); | |||
const populate = (select, value) => { | |||
select.empty(); | |||
variables.forEach(name => { | |||
$('<option>') | |||
.attr('value', name) | |||
.text(name) | |||
.appendTo(select); | |||
}); | |||
if (value && variables.includes(value)) { | |||
select.val(value); | |||
} else if (variables.length) { | |||
select.val(variables[0]); | |||
} | |||
}; | |||
populate( | |||
this.editorTools.variableSelect, | |||
current | |||
); | |||
populate( | |||
this.editorTools.conditionVariableSelect, | |||
currentCondition | |||
); | ); | ||
}, | }, | ||
updateConditionVariableVisibility() { | |||
if (!this.editorTools) return; | |||
const type = | |||
const | this.editorTools.conditionTypeSelect.val(); | ||
this.editorTools.conditionVariableWrap.toggle( | |||
type === 'if' | |||
); | |||
) | |||
}, | }, | ||
renderEditorTools() { | |||
const tools = $('<div>') | |||
.addClass('preload-editor-tools'); | |||
const | |||
// Wrap selection | |||
const | const wrapGroup = $('<div>') | ||
.addClass( | |||
'preload-editor-tool-group preload-editor-tool-group-wrap' | |||
); | |||
wrapGroup.append( | |||
this.createButton( | |||
'Wrap selection with %%', | |||
'preload-editor-tool-button', | |||
() => this.wrapSelectionWithVariable() | |||
) | |||
); | |||
// Insert existing variable | |||
const variableGroup = $('<div>') | |||
.addClass( | |||
'preload-editor-tool-group preload-editor-tool-group-variable' | |||
); | |||
$('<span>') | |||
.addClass('preload-editor-tool-label') | |||
.text('Insert existing variable') | |||
.appendTo(variableGroup); | |||
const variableSelect = this.createToolSelect( | |||
[], | |||
'preload-editor-variable-select' | |||
); | |||
variableGroup.append( | |||
variableSelect, | |||
this.createButton( | |||
'Insert', | |||
'preload-editor-tool-button', | |||
() => this.insertVariable(variableSelect.val()) | |||
) | |||
); | |||
// Insert condition | |||
const conditionGroup = $('<div>') | |||
.addClass( | |||
'preload-editor-tool-group preload-editor-tool-group-condition' | |||
); | ); | ||
$('<span>') | |||
.addClass('preload-editor-tool-label') | |||
.text('Insert condition') | |||
.appendTo(conditionGroup); | |||
const conditionTypeSelect = this.createToolSelect( | |||
[ | |||
{ value: 'if', label: '@if' }, | |||
{ value: 'elseif', label: '@elseif' }, | |||
{ value: 'else', label: '@else' }, | |||
{ value: 'endif', label: '@endif' } | |||
], | |||
'preload-editor-condition-type-select' | |||
); | |||
const conditionVariableSelect = this.createToolSelect( | |||
[], | |||
'preload-editor-condition-variable-select' | |||
); | |||
const conditionVariableWrap = $('<span>') | |||
.addClass( | |||
'preload-editor-condition-variable' | |||
) | |||
.append(conditionVariableSelect); | |||
) | |||
const insertCondition = this.createButton( | |||
'Insert', | |||
'preload-editor-tool-button', | |||
() => this.insertCondition( | |||
conditionTypeSelect.val(), | |||
conditionVariableSelect.val() | |||
) | |||
); | |||
conditionTypeSelect.on( | |||
'change.preloadEditor', | |||
() => this.updateConditionVariableVisibility() | |||
); | |||
conditionGroup.append( | |||
conditionTypeSelect, | |||
conditionVariableWrap, | |||
insertCondition | |||
); | |||
tools.append( | |||
wrapGroup, | |||
variableGroup, | |||
conditionGroup | |||
); | |||
this.editorTools = { | |||
root: tools, | |||
variableSelect, | |||
conditionTypeSelect, | |||
conditionVariableSelect, | |||
conditionVariableWrap, | |||
insertCondition | |||
}; | |||
this.updateEditorToolVariables(); | |||
this.updateConditionVariableVisibility(); | |||
return tools; | |||
}, | |||
updateEditorTools() { | |||
this.updateEditorToolVariables(); | |||
this.updateConditionVariableVisibility(); | |||
}, | |||
// Preload syntax highlighting | |||
getKnownVariables() { | |||
const known = new Set(['PAGE']); | |||
const fields = this.state.config?.fields; | |||
if (fields && typeof fields === 'object') { | |||
Object.keys(fields).forEach(name => { | |||
known.add(String(name).toUpperCase()); | |||
}); | |||
} | |||
} | } | ||
return | return known; | ||
}, | }, | ||
createSyntaxDecorations(state, Decoration) { | |||
const decorations = []; | |||
const source = state.doc.toString(); | |||
const knownVariables = this.getKnownVariables(); | |||
const | const variablePattern = /%%([A-Za-z0-9_-]+)%%/g; | ||
let match; | |||
while ((match = variablePattern.exec(source)) !== null) { | |||
const name = match[1].toUpperCase(); | |||
const known = knownVariables.has(name); | |||
decorations.push( | |||
Decoration.mark({ | |||
class: [ | |||
'preload-editor-syntax-variable', | |||
known | |||
? 'preload-editor-syntax-variable-known' | |||
) | : 'preload-editor-syntax-variable-unknown' | ||
].join(' ') | |||
}).range( | |||
match.index, | |||
match.index + match[0].length | |||
) = | ) | ||
); | |||
} | |||
const conditionPattern = | |||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | |||
while ((match = conditionPattern.exec(source)) !== null) { | |||
decorations.push( | |||
Decoration.mark({ | |||
class: 'preload-editor-syntax-condition' | |||
}, | }).range( | ||
match.index, | |||
match.index + match[0].length | |||
) | |||
); | |||
const directiveStart = | |||
match.index + match[0].indexOf('@'); | |||
decorations.push( | |||
Decoration.mark({ | |||
class: 'preload-editor-syntax-directive' | |||
}).range( | |||
directiveStart, | |||
directiveStart + match[1].length + 1 | |||
) | ) | ||
); | |||
const type = match[1].toLowerCase(); | |||
if (type !== 'if' && type !== 'elseif') continue; | |||
const expression = match[2]; | |||
const expressionStart = | |||
match.index + match[0].indexOf(expression); | |||
const expressionPattern = /[A-Za-z0-9_-]+/g; | |||
let expressionMatch; | |||
while ( | |||
(expressionMatch = | |||
expressionPattern.exec(expression)) !== null | |||
) { | |||
const name = | |||
expressionMatch[0].toUpperCase(); | |||
if (!knownVariables.has(name)) continue; | |||
const start = | |||
expressionStart + expressionMatch.index; | |||
decorations.push( | |||
Decoration.mark({ | |||
class: | |||
'preload-editor-syntax-condition-variable' | |||
}).range( | |||
start, | |||
start + expressionMatch[0].length | |||
) | |||
); | ); | ||
} | } | ||
} | } | ||
return | return Decoration.set(decorations, true); | ||
}, | }, | ||
async installSyntaxHighlighting(view) { | |||
await mw.loader.using('ext.CodeMirror.v6.lib'); | |||
const require = mw.loader.require; | |||
const { | |||
StateField, | |||
StateEffect, | |||
EditorView, | |||
Decoration | |||
} = require('ext.CodeMirror.v6.lib'); | |||
const syntaxField = StateField.define({ | |||
create: state => | |||
this.createSyntaxDecorations(state, Decoration), | |||
update: (decorations, transaction) => | |||
transaction.docChanged | |||
? this.createSyntaxDecorations( | |||
transaction.state, | |||
Decoration | |||
) | |||
: decorations, | |||
provide: field => | |||
EditorView.decorations.from(field) | |||
}); | |||
view.dispatch({ | |||
effects: StateEffect.appendConfig.of(syntaxField) | |||
}); | }); | ||
this.editor.syntaxField = syntaxField; | |||
}, | }, | ||
// CodeMirror mounting | |||
async mountCodeMirror(textarea) { | |||
return new Promise((resolve, reject) => { | |||
let settled = false; | |||
const cleanup = () => { | |||
mw.hook('dev.CCM.ready').remove(onReady); | |||
mw.hook('dev.CCM.load').remove(onLoad); | |||
}; | |||
const onReady = async cm => { | |||
if ( | |||
settled || | |||
!cm || | |||
cm.textarea !== textarea || | |||
!cm.view | |||
) return; | |||
settled = true; | |||
cleanup(); | |||
const view = cm.view; | |||
const inputHandler = () => { | |||
this.state.preloadContent = | |||
view.state.doc.toString(); | |||
this.state.dirty = true; | |||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.renderConfigPreviewBySelector(); | |||
this.updateEditorTools(); | |||
}; | |||
} | |||
view.dom.addEventListener( | |||
'input', | |||
inputHandler | |||
); | ); | ||
view.dom.classList.add( | |||
'preload-editor-codemirror' | |||
); | ); | ||
const wrapper = | |||
view.dom.closest( | |||
'.ext-codemirror-wrapper' | |||
); | |||
if (wrapper) { | |||
wrapper.classList.add( | |||
'preload-editor-codemirror-wrapper' | |||
); | |||
} | |||
this.editor = { | |||
cm, | |||
view, | |||
textarea, | |||
inputHandler | |||
}; | |||
this.editorView = view; | |||
this.state.preloadContent = | |||
. | view.state.doc.toString(); | ||
try { | |||
await this.installSyntaxHighlighting(view); | |||
resolve(view); | |||
} catch (error) { | |||
reject(error); | |||
} | |||
}; | |||
const onLoad = loader => { | |||
if ( | |||
settled || | |||
typeof loader !== 'function' | |||
) return; | |||
try { | |||
loader(textarea); | |||
} catch (error) { | |||
settled = true; | |||
cleanup(); | |||
reject(error); | |||
} | |||
); | }; | ||
} | |||
mw.hook('dev.CCM.ready').add(onReady); | |||
mw.hook('dev.CCM.load').add(onLoad); | |||
}); | |||
}, | |||
// Main UI | |||
const | init() { | ||
const root = $('#mw-content-text'); | |||
if (!root.length) return; | |||
$('< | this.root = $('<div>') | ||
. | .attr('id', 'preload-editor') | ||
.addClass('preload-editor'); | |||
. | |||
root.append(this.root); | |||
this.renderShell(); | |||
}, | |||
renderShell() { | |||
this.destroyEditor(); | |||
this.root.empty(); | |||
const loader = this.createSection('Preload'); | |||
const | const titleInput = this.textInput( | ||
this. | 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() | |||
); | ); | ||
this. | const sampleButton = this.createButton( | ||
'Load Sample Page', | |||
'', | |||
() => this.loadSamplePage() | |||
); | |||
loader.append( | |||
$('<div>') | |||
.addClass('preload-editor-toolbar') | |||
.append( | |||
titleInput, | |||
loadButton, | |||
newButton, | |||
sampleButton | |||
) | |||
); | |||
this.root.append(loader); | |||
this.status = $('<div>') | |||
.addClass('preload-editor-status'); | |||
this.root.append(this.status); | |||
this.workspace = $('<div>') | |||
.addClass('preload-editor-workspace'); | |||
( | |||
this.root.append(this.workspace); | |||
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 => | |||
return; | /\/Preload$/i.test(result.title) | ||
); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor search:', | |||
error | |||
); | |||
return []; | |||
} | } | ||
}, | |||
renderMissingPage(title, suggestions) { | |||
this.workspace.empty(); | |||
const section = this.createSection('Preload not found'); | |||
$('<p>') | |||
.text('No preload exists at "' + title + '".') | |||
.appendTo(section); | |||
if (suggestions.length) { | |||
$('<h3>') | |||
' | .text('Similar preloads') | ||
.appendTo(section); | |||
const list = $('<div>') | |||
.addClass('preload-editor-suggestions'); | |||
suggestions.forEach(result => { | |||
list.append( | |||
this.createButton( | |||
result.title, | |||
'preload-editor-suggestion', | |||
() => this.loadExisting(result.title) | |||
) | ) | ||
); | ); | ||
}); | |||
section.append(list); | |||
} | |||
section.append( | |||
this.createButton( | this.createButton( | ||
' | 'Create this preload', | ||
'', | 'preload-editor-button-primary', | ||
() => | () => this.startNew(title) | ||
) | |||
); | ); | ||
this.workspace.append(section); | |||
}, | |||
async startNew(title) { | |||
title = String(title || '').trim(); | |||
this. | this.state = { | ||
preloadTitle: title, | |||
preloadSource: '', | |||
preloadContent: this.defaults.preload, | |||
config: this.emptyConfig(), | |||
variables: ['PAGE'], | |||
conditions: [], | |||
mode: 'new', | |||
dirty: true | |||
}; | |||
this. | this.renderEditor(); | ||
this. | this.setStatus( | ||
'Creating a new preload.', | |||
'success' | |||
); | ); | ||
}, | |||
this. | async loadSamplePage() { | ||
const result = await this.createPromptDialog( | |||
'Load Sample Page', | |||
'Enter the title of a normal wiki page to use as the starting point.' | |||
); | |||
if (!result) return; | |||
this.setStatus( | |||
'Loading sample page...', | |||
'loading' | |||
); | ); | ||
try { | |||
const page = await this.getPage(result); | |||
if (!page) { | |||
throw new Error( | |||
'Sample page not found: ' + result | |||
' | |||
); | ); | ||
} | |||
const sample = | |||
this.convertSampleToPreload(page.content); | |||
. | |||
this.state = { | |||
preloadTitle: '', | |||
preloadSource: '', | |||
preloadContent: sample, | |||
config: this.emptyConfig(), | |||
variables: ['PAGE'], | |||
conditions: [], | |||
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(); | |||
const include = content.match( | |||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | ); | ||
if (include) content = include[1].trim(); | |||
content = content | |||
.replace( | |||
/<noinclude>[\s\S]*?<\/noinclude>/gi, | |||
'' | |||
) | |||
.trim(); | |||
) | |||
return ( | |||
' | '<includeonly>\n' + | ||
' | content + | ||
'\n</includeonly>\n' + | |||
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n' | |||
); | ); | ||
}, | |||
createPromptDialog(title, message) { | |||
return new Promise(resolve => { | |||
const overlay = $('<div>') | |||
.addClass('preload-editor-prompt-overlay'); | |||
const box = $('<div>') | |||
const | .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); | |||
} | |||
); | ); | ||
this. | 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(); | |||
this. | |||
const | const configTitle = | ||
title.replace(/\/+$/, '') + | |||
'/config.json'; | |||
const configPage = | |||
await this.getPage(configTitle); | |||
. | |||
if ( | if (configPage) { | ||
try { | |||
. | config = JSON.parse(configPage.content); | ||
} catch (error) { | |||
throw new Error( | |||
'The existing config.json contains invalid JSON: ' + | |||
error.message | |||
); | |||
} | |||
. | |||
} | } | ||
const analysis = | |||
this. | this.analyzePreload(content); | ||
this. | config = this.prepareConfigForVariables( | ||
config, | |||
analysis | |||
); | ); | ||
this.state = { | this.state = { | ||
preloadTitle: title, | preloadTitle: title, | ||
preloadSource: | preloadSource: source, | ||
preloadContent: | preloadContent: source, | ||
config, | |||
config | variables: analysis.variables, | ||
conditions: analysis.conditions, | |||
variables: | mode: 'edit', | ||
conditions: | dirty: false | ||
mode: ' | |||
dirty: | |||
}; | }; | ||
this.renderEditor(); | this.renderEditor(); | ||
}, | |||
// Editor | |||
renderEditor() { | |||
this.destroyEditor(); | |||
this.workspace.empty(); | |||
const layout = $('<div>') | |||
.addClass('preload-editor-layout'); | |||
' | |||
const left = $('<div>') | |||
const | .addClass('preload-editor-left'); | ||
const right = $('<div>') | |||
.addClass('preload-editor-right'); | |||
this. | const editorSection = this.createSection( | ||
' | 'Preload', | ||
' | 'preload-editor-main-section' | ||
); | ); | ||
const meta = $('<div>') | |||
.addClass('preload-editor-meta'); | |||
$('<strong>') | |||
.text(this.state.preloadTitle || 'New preload') | |||
.appendTo(meta); | |||
if (this.state.mode === 'new') { | |||
$('<span>') | |||
.addClass('preload-editor-badge') | |||
); | .text('NEW') | ||
.appendTo(meta); | |||
} | |||
editorSection.append(meta); | |||
editorSection.append( | |||
this.renderEditorTools() | |||
); | |||
this.state | const textarea = this.textarea( | ||
this.state.preloadContent | |||
) | |||
.attr('id', 'preload-editor-content') | |||
.addClass('preload-editor-content'); | |||
const editorMount = $('<div>') | |||
.addClass('preload-editor-codemirror-mount') | |||
.append(textarea); | |||
editorSection.append(editorMount); | |||
left.append(editorSection); | |||
this. | const analysisSection = | ||
this.createSection('Analysis'); | |||
const analysisContainer = $('<div>') | |||
.addClass('preload-editor-analysis'); | |||
analysisSection.append(analysisContainer); | |||
const variablesSection = | |||
this.createSection('Variables'); | |||
const variableContainer = $('<div>') | |||
.addClass('preload-editor-variables'); | |||
variablesSection.append(variableContainer); | |||
const configSection = | |||
this.createSection('Generated configuration'); | |||
const configPreview = $('<pre>') | |||
.addClass('preload-editor-config-preview'); | |||
configSection.append(configPreview); | |||
const actions = $('<div>') | |||
.addClass('preload-editor-actions'); | |||
actions.append( | |||
this.createButton( | |||
'Save Preload + Config', | |||
'preload-editor-button-primary', | |||
() => this.saveAll() | |||
), | |||
this.createButton( | |||
'Save Preload Only', | |||
'', | |||
() => this.savePreloadOnly() | |||
) | |||
); | |||
right.append( | |||
analysisSection, | |||
variablesSection, | |||
configSection, | |||
actions | |||
); | |||
layout.append(left, right); | |||
this.workspace.append(layout); | |||
this.reanalyze(); | |||
this.renderVariables(variableContainer); | |||
this.renderAnalysis(analysisContainer); | |||
this.renderConfigPreview(configPreview); | |||
this.updateEditorTools(); | |||
this.mountCodeMirror(textarea.get(0)).catch(error => { | |||
console.error( | |||
'Preload Editor CodeMirror:', | |||
error | |||
); | |||
this.setStatus( | |||
error.message || | |||
'Could not initialize CodeMirror.', | |||
'error' | |||
); | |||
}); | |||
}, | |||
reanalyze() { | |||
const source = this.getEditorContent(); | |||
const content = this.extractIncludeonly(source); | |||
const analysis = this.analyzePreload(content); | |||
this.state.preloadContent = source; | |||
this.state.variables = analysis.variables; | |||
this.state.conditions = analysis.conditions; | |||
this.state.config = | |||
this.prepareConfigForVariables( | |||
this.state.config || | |||
); | this.emptyConfig(), | ||
analysis | |||
); | |||
this.analysis = analysis; | |||
}, | |||
renderAnalysis(container) { | |||
container.empty(); | |||
const analysis = | |||
this.analysis || | |||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
this.getEditorContent() | |||
) | |||
); | ); | ||
const variableCount = analysis.variables.length; | |||
const conditionCount = analysis.conditions.length; | |||
const | const summary = $('<div>') | ||
.addClass('preload-editor-analysis-summary'); | |||
' | |||
summary.append( | |||
$('<span>').text( | |||
variableCount + | |||
' variable' + | |||
(variableCount === 1 ? '' : 's') | |||
), | |||
$('<span>').text( | |||
conditionCount + | |||
' condition marker' + | |||
(conditionCount === 1 ? '' : 's') | |||
) | |||
); | |||
container.append(summary); | |||
if (analysis.conditionVariables.length) { | |||
container.append( | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis-note' | |||
) | |||
.text( | |||
'Condition variables: ' + | |||
analysis.conditionVariables.join(', ') | |||
) | |||
); | ); | ||
} | |||
const validation = this.validateConfig( | |||
this. | this.state.config, | ||
analysis | |||
); | |||
if (validation.errors.length) { | |||
const errors = $('<ul>') | |||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-validation-errors' | ||
); | ); | ||
validation.errors.forEach(error => { | |||
$('<li>').text(error).appendTo(errors); | |||
}); | |||
container.append(errors); | |||
} | |||
const | if (validation.warnings.length) { | ||
const warnings = $('<ul>') | |||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-validation-warnings' | ||
); | ); | ||
validation.warnings.forEach(warning => { | |||
$('<li>').text(warning).appendTo(warnings); | |||
' | }); | ||
); | |||
container.append(warnings); | |||
} | |||
$('< | if (!validation.errors.length && | ||
!validation.warnings.length) { | |||
container.append( | |||
' | $('<div>') | ||
.addClass( | |||
'preload-editor-validation-success' | |||
) | |||
.text( | |||
'Preload and configuration are in sync.' | |||
) | |||
); | |||
} | |||
}, | |||
renderAnalysisBySelector() { | |||
const container = | |||
this.root?.find( | |||
'.preload-editor-analysis' | |||
); | |||
if ( | if (container?.length) { | ||
this.renderAnalysis(container); | |||
} | } | ||
}, | |||
renderVariables(container) { | |||
container.empty(); | |||
const | const analysis = | ||
this. | this.analysis || | ||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
this.getEditorContent() | |||
this. | |||
this. | |||
) | ) | ||
); | |||
const | const variables = analysis.variables.slice(); | ||
const ordered = ['PAGE']; | |||
variables.forEach(name => { | |||
if (name !== 'PAGE' && | |||
); | !ordered.includes(name)) { | ||
ordered.push(name); | |||
} | |||
}); | |||
Object.keys( | |||
this.state.config.fields || {} | |||
); | ).forEach(name => { | ||
const upper = name.toUpperCase(); | |||
if (!ordered.includes(upper)) { | |||
ordered.push(upper); | |||
} | |||
); | }); | ||
const | ordered.forEach(name => { | ||
const field = | |||
. | this.state.config.fields[name] || | ||
this.createDefaultField(name, analysis); | |||
this.state.config.fields[name] = field; | |||
container.append( | |||
this.createVariableCard( | |||
name, | |||
field, | |||
analysis | |||
) | |||
); | ); | ||
}); | |||
}, | |||
const | renderVariablesBySelector() { | ||
const container = | |||
. | this.root?.find( | ||
'.preload-editor-variables' | |||
); | ); | ||
if (container?.length) { | |||
this.renderVariables(container); | |||
} | |||
}, | |||
createVariableCard(name, field, analysis) { | |||
const card = $('<div>') | |||
.addClass('preload-editor-variable') | |||
.attr('data-variable', name); | |||
const | const header = $('<div>') | ||
.addClass('preload-editor-variable-header'); | |||
header.append($('<h3>').text(name)); | |||
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); | |||
const label = this.textInput(field.label || ''); | |||
label.on('input.preloadEditor', () => { | |||
field.label = label.val(); | |||
this.markDirty(); | |||
); | this.renderConfigPreviewBySelector(); | ||
}); | |||
this. | card.append(this.fieldRow('Label', label)); | ||
this. | const description = this.textarea( | ||
field.description || '' | |||
).addClass('preload-editor-small-textarea'); | |||
) | |||
description.on('input.preloadEditor', () => { | |||
field.description = description.val(); | |||
this.markDirty(); | |||
this.renderConfigPreviewBySelector(); | |||
}); | |||
card.append( | |||
this.fieldRow( | |||
'Description', | |||
description | |||
) | |||
); | ); | ||
const types = [ | |||
const | ['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, | |||
label | |||
})), | |||
field.type || 'text' | |||
); | |||
typeSelect.on('change.preloadEditor', () => { | |||
field.type = typeSelect.val(); | |||
this.markDirty(); | |||
); | |||
card.replaceWith( | |||
this.createVariableCard( | |||
name, | |||
field, | |||
analysis | |||
) | |||
); | ); | ||
this.renderConfigPreviewBySelector(); | |||
}); | |||
this. | card.append( | ||
this.fieldRow( | |||
'Type', | |||
typeSelect | |||
) | |||
); | |||
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', | |||
this. | 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; | |||
} | } | ||
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 | |||
) | |||
); | |||
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( | |||
this. | 'Placeholder', | ||
placeholder | |||
) | |||
); | |||
const | 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. | 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.' | |||
) | |||
); | ); | ||
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 | |||
) | |||
); | ); | ||
} | |||
if ( | if (field.type === 'select' || | ||
field.type === 'multiselect') { | |||
this.renderSelectConfiguration( | |||
card, | |||
this. | field | ||
); | ); | ||
} | } | ||
return card; | |||
}, | }, | ||
renderSelectConfiguration(card, field) { | |||
const source = this.textInput( | |||
field.source || '', | |||
'category:Example' | |||
); | |||
const | |||
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( | card.append( | ||
this.fieldRow( | |||
'Source prefix', | |||
sourcePrefix, | |||
'Only source values beginning with this text are included.' | |||
) | |||
); | |||
const | const excludeCategory = this.textInput( | ||
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( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Exclude category', | ||
excludeCategory | |||
) | ) | ||
); | ); | ||
card.append( | |||
this. | this.createTransformControls( | ||
field. | field, | ||
'value', | |||
'Value transformation' | |||
), | |||
this.createTransformControls( | |||
field, | |||
'label', | |||
'Label transformation' | |||
) | ) | ||
); | |||
const optionsValue = | |||
Array.isArray(field.options) | |||
? field.options.map(option => { | |||
field. | 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( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Manual options', | ||
options, | |||
'One per line. Optional label format: value | label. Leave empty when using a source.' | |||
) | ) | ||
); | ); | ||
}, | |||
createTransformControls(field, property, title) { | |||
const wrapper = $('<div>') | |||
.addClass('preload-editor-transform'); | |||
$('<h4>') | |||
.text(title) | |||
.appendTo(wrapper); | |||
const existing = | |||
field[property] && | |||
typeof field[property] === 'object' | |||
? field[property] | |||
: {}; | |||
const transform = this.select( | |||
[ | |||
{ | |||
value: '', | |||
label: 'No transformation' | |||
}, | |||
{ | |||
value: 'remove-prefix', | |||
label: 'Remove prefix' | |||
}, | |||
{ | |||
value: 'remove-suffix', | |||
label: 'Remove suffix' | |||
}, | |||
{ | |||
value: 'replace', | |||
label: 'Replace' | |||
} | } | ||
); | ], | ||
existing.transform || '' | |||
); | |||
const prefix = this.textInput( | |||
existing.prefix || '' | |||
); | |||
const suffix = this.textInput( | |||
existing.suffix || '' | |||
); | |||
const from = this.textInput( | |||
existing.from || '' | |||
); | |||
const to = this.textInput( | |||
existing.to || '' | |||
); | |||
const controls = $('<div>') | |||
.addClass( | |||
'preload-editor-transform-controls' | |||
); | ); | ||
const prefixRow = | |||
this.fieldRow('Prefix', prefix); | |||
const suffixRow = | |||
this.fieldRow('Suffix', suffix); | |||
); | |||
const fromRow = | |||
this.fieldRow('From', from); | |||
const toRow = | |||
this.fieldRow('To', to); | |||
const renderInputs = () => { | |||
const type = transform.val(); | |||
prefixRow.toggle( | |||
type === 'remove-prefix' | |||
); | ); | ||
suffixRow.toggle( | |||
type === 'remove-suffix' | |||
); | ); | ||
fromRow.toggle(type === 'replace'); | |||
} | toRow.toggle(type === 'replace'); | ||
}; | |||
const | const update = () => { | ||
const type = transform.val(); | |||
let object = null; | |||
if (type === 'remove-prefix' && prefix.val()) { | |||
' | object = { | ||
transform: type, | |||
prefix: prefix.val() | |||
}; | |||
} else if ( | |||
); | type === 'remove-suffix' && | ||
suffix.val() | |||
) { | |||
object = { | |||
transform: type, | |||
suffix: suffix.val() | |||
}; | |||
} else if (type === 'replace' && from.val()) { | |||
object = { | |||
transform: type, | |||
from: from.val(), | |||
to: to.val() | |||
}; | |||
} | |||
if (object) { | |||
field[property] = object; | |||
} else { | |||
delete field[property]; | |||
} | } | ||
this.markDirty(); | |||
this. | this.renderConfigPreviewBySelector(); | ||
}; | |||
transform.on( | |||
'change.preloadEditor', | |||
' | |||
() => { | () => { | ||
update(); | |||
renderInputs(); | |||
} | } | ||
); | ); | ||
[prefix, suffix, from, to].forEach(input => { | |||
input.on( | |||
' | 'input.preloadEditor', | ||
update | |||
); | ); | ||
}); | |||
controls.append( | |||
' | this.fieldRow( | ||
() | 'Transformation', | ||
transform | |||
), | |||
prefixRow, | |||
suffixRow, | |||
fromRow, | |||
toRow | |||
); | |||
renderInputs(); | |||
wrapper.append(controls); | |||
return wrapper; | |||
}, | |||
// Config output | |||
getCleanConfig() { | |||
const config = JSON.parse( | |||
JSON.stringify(this.state.config) | |||
); | ); | ||
const fields = config.fields || {}; | |||
const orderedFields = {}; | |||
if ( | 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) { | |||
container.text(this.getConfigJSON()); | |||
} | |||
}, | |||
renderConfigPreviewBySelector() { | |||
const preview = | |||
this.root?.find( | |||
'.preload-editor-config-preview' | |||
); | ); | ||
if (preview?.length) { | |||
this.renderConfigPreview(preview); | |||
} | |||
} | |||
this.renderAnalysisBySelector(); | |||
}, | |||
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; | |||
} | |||
this.state.preloadContent = | |||
this. | this.getEditorContent(); | ||
const | const validation = | ||
this. | 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( | |||
this. | 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) { | |||
this. | const result = | ||
await this.createPromptDialog( | |||
'Preload title', | |||
'Enter the full title for the preload.' | |||
); | |||
if (!result) return; | |||
title = result; | |||
this.state.preloadTitle = title; | |||
} | |||
this.state.preloadContent = | |||
this. | this.getEditorContent(); | ||
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' | |||
); | |||
this. | try { | ||
const preloadExists = | |||
await this.pageExists(title); | |||
this. | 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 | |||
); | |||
'preload | |||
); | |||
this.state.mode = 'edit'; | |||
. | this.state.dirty = false; | ||
. | this.state.preloadTitle = title; | ||
this.setStatus( | |||
'Preload and config saved successfully.', | |||
'success' | |||
this. | |||
); | ); | ||
mw.notify( | |||
'Preload and config saved.', | |||
{ type: 'success' } | |||
); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor save:', | |||
error | |||
); | ); | ||
this.setStatus( | |||
this. | error.message || | ||
'Could not save preload/config.', | |||
'error' | |||
); | ); | ||
mw.notify( | |||
error.message || | |||
'Could not save preload/config.', | |||
{ type: 'error' } | |||
); | ); | ||
} | |||
}, | |||
const | validateBeforeSave() { | ||
const errors = []; | |||
const warnings = []; | |||
const | 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 | const content = | ||
this. | this.getEditorContent(); | ||
this.state.preloadContent = content; | |||
if (!content.trim()) { | |||
errors.push( | |||
'The preload cannot be empty.' | |||
); | ); | ||
} | |||
if (!/<includeonly>/i.test(content)) { | |||
warnings.push( | |||
'The preload does not contain an <includeonly> section.' | |||
); | ); | ||
} | |||
if (!/<noinclude>/i.test(content)) { | |||
warnings.push( | |||
'The preload does not contain a <noinclude> section.' | |||
); | ); | ||
} | |||
const analysis = | |||
this.analyzePreload( | |||
this.extractIncludeonly(content) | |||
); | |||
const configValidation = | |||
this.validateConfig( | |||
this.state.config, | |||
analysis | |||
); | ); | ||
errors.push(...configValidation.errors); | |||
warnings.push(...configValidation.warnings); | |||
let depth = 0; | |||
analysis.conditions.forEach(condition => { | |||
if (condition.type === 'if') { | |||
depth++; | |||
} else if (condition.type === 'endif') { | |||
depth--; | |||
if (depth < 0) { | |||
errors.push( | |||
'Found @endif without a matching @if.' | |||
); | |||
depth = 0; | |||
} | } | ||
} else if ( | } else if ( | ||
type === | condition.type === 'elseif' || | ||
' | condition.type === 'else' | ||
) { | ) { | ||
if ( | if (depth === 0) { | ||
errors.push( | |||
'@' + | |||
condition.type + | |||
' exists outside an @if block.' | |||
); | |||
} | } | ||
} | } | ||
}); | |||
} | |||
if (depth > 0) { | if (depth > 0) { | ||
| Line 4,164: | Line 2,730: | ||
errors, | errors, | ||
warnings, | warnings, | ||
valid: | valid: errors.length === 0 | ||
}; | }; | ||
}, | }, | ||
showValidationErrors( | showValidationErrors(validation) { | ||
this.renderAnalysisBySelector(); | this.renderAnalysisBySelector(); | ||
this.setStatus( | this.setStatus( | ||
validation.errors.join( | validation.errors.join(' '), | ||
'error' | 'error' | ||
); | ); | ||
mw.notify( | mw.notify( | ||
validation.errors.join( | validation.errors.join('\n'), | ||
{ type: 'error' } | |||
{ | |||
); | ); | ||
} | } | ||
}; | }; | ||
mw.PreloadEditor = | mw.PreloadEditor = PreloadEditor; | ||
$(function () { | $(function () { | ||
if ( | if ( | ||
mw.config.get('wgPageName') !== | |||
'Petit_Planet:Preload_Editor' | 'Petit_Planet:Preload_Editor' | ||
) { | ) { | ||