MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
Appearance
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| Line 3: | Line 3: | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: '1. | version: '1.1.0', | ||
state: { | state: { | ||
| Line 826: | Line 826: | ||
// ========================================================= | // ========================================================= | ||
// Preload parsing | // Config import helpers | ||
// ========================================================= | // ========================================================= | ||
extractIncludeonly(source) { | /* | ||
const match = | * Accept either: | ||
String(source || '').match( | * | ||
/<includeonly>([\s\S]*?)<\/includeonly>/i | * Template:Example/Preload | ||
); | * | ||
* or: | |||
return match | * | ||
? match[1].trim() | * Template:Example/Preload/config.json | ||
: String(source || '').trim(); | * | ||
}, | * and always return the config.json title. | ||
*/ | |||
findVariables(content) { | normalizeConfigTitle(title) { | ||
const variables = []; | title = | ||
const pattern = | String(title || '').trim(); | ||
/%%([A-Za-z0-9_-]+)%%/g; | |||
title = | |||
let match; | title.replace( | ||
/\/+$/, | |||
while ( | '' | ||
(match = pattern.exec(content)) !== null | ); | ||
) { | |||
const name = | if ( | ||
match[1].toUpperCase(); | /\/config\.json$/i.test( | ||
title | |||
if (!variables.includes(name)) { | ) | ||
variables.push(name); | ) { | ||
} | return title; | ||
} | } | ||
return variables; | return title + | ||
}, | '/config.json'; | ||
}, | |||
findConditions(content) { | |||
const conditions = []; | /* | ||
const pattern = | * Return a safe clone of a config field. | ||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | * | ||
* This is deliberately JSON-based because config | |||
let match; | * definitions are JSON-compatible objects. | ||
*/ | |||
while ( | cloneConfigField(field) { | ||
(match = pattern.exec(content)) !== null | try { | ||
) { | return JSON.parse( | ||
conditions.push({ | JSON.stringify(field) | ||
type: | ); | ||
match[1].toLowerCase(), | } catch (error) { | ||
console.warn( | |||
expression: | 'Preload Editor: Could not clone imported field.', | ||
match[2].trim(), | error | ||
); | |||
index: | |||
match.index | return null; | ||
}); | } | ||
} | }, | ||
return conditions; | /* | ||
}, | * Normalize imported field names to the same uppercase | ||
* convention used throughout the editor. | |||
*/ | |||
getImportableFields(sourceConfig) { | |||
const fields = | |||
sourceConfig && | |||
sourceConfig.fields; | |||
if ( | |||
!fields || | |||
typeof fields !== 'object' || | |||
Array.isArray(fields) | |||
) { | |||
return {}; | |||
} | |||
const result = {}; | |||
Object.entries( | |||
fields | |||
).forEach( | |||
([name, field]) => { | |||
const upper = | |||
String( | |||
name || '' | |||
) | |||
.trim() | |||
.toUpperCase(); | |||
if ( | |||
!upper || | |||
upper === 'PAGE' | |||
) { | |||
return; | |||
} | |||
if ( | |||
!field || | |||
typeof field !== 'object' || | |||
Array.isArray(field) | |||
) { | |||
return; | |||
} | |||
const clone = | |||
this.cloneConfigField( | |||
field | |||
); | |||
if (!clone) { | |||
return; | |||
} | |||
result[upper] = | |||
clone; | |||
} | |||
); | |||
return result; | |||
}, | |||
/* | |||
* Show the variable-selection dialog after a source | |||
* config has been loaded. | |||
* | |||
* Resolves: | |||
* null -> cancelled | |||
* [] -> confirmed with no selections | |||
* [...] -> selected field names | |||
*/ | |||
createImportDefinitionsDialog( | |||
sourceTitle, | |||
fields | |||
) { | |||
return new Promise( | |||
resolve => { | |||
const names = | |||
Object.keys( | |||
fields | |||
).sort( | |||
(a, b) => | |||
a.localeCompare( | |||
b | |||
) | |||
); | |||
const overlay = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt-overlay preload-editor-import-overlay' | |||
); | |||
const box = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt preload-editor-import-dialog' | |||
); | |||
$('<h2>') | |||
.text( | |||
'Import variable definitions' | |||
) | |||
.appendTo(box); | |||
$('<p>') | |||
.text( | |||
'Choose which variable definitions to import from "' + | |||
sourceTitle + | |||
'". Existing local definitions can be replaced.' | |||
) | |||
.appendTo(box); | |||
if (!names.length) { | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-empty preload-editor-import-empty' | |||
) | |||
.text( | |||
'This config.json does not contain any importable variable definitions.' | |||
) | |||
.appendTo(box); | |||
const emptyButtons = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt-buttons' | |||
); | |||
const close = | |||
this.createButton( | |||
'Close', | |||
'preload-editor-button-primary', | |||
() => { | |||
overlay.remove(); | |||
resolve(null); | |||
} | |||
); | |||
emptyButtons.append( | |||
close | |||
); | |||
box.append( | |||
emptyButtons | |||
); | |||
overlay.append(box); | |||
$(document.body) | |||
.append(overlay); | |||
overlay.attr( | |||
'tabindex', | |||
'-1' | |||
); | |||
overlay.trigger('focus'); | |||
return; | |||
} | |||
const search = | |||
this.textInput( | |||
'', | |||
'Search variables...' | |||
) | |||
.addClass( | |||
'preload-editor-import-search' | |||
); | |||
const searchWrap = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-import-search-wrap' | |||
) | |||
.append(search); | |||
box.append( | |||
searchWrap | |||
); | |||
const controls = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-import-controls' | |||
); | |||
const selectAll = | |||
this.createButton( | |||
'Select all', | |||
'', | |||
() => { | |||
list | |||
.find( | |||
'input[type="checkbox"]:visible' | |||
) | |||
.prop( | |||
'checked', | |||
true | |||
); | |||
updateSummary(); | |||
} | |||
); | |||
const deselectAll = | |||
this.createButton( | |||
'Deselect all', | |||
'', | |||
() => { | |||
list | |||
.find( | |||
'input[type="checkbox"]:visible' | |||
) | |||
.prop( | |||
'checked', | |||
false | |||
); | |||
updateSummary(); | |||
} | |||
); | |||
controls.append( | |||
selectAll, | |||
deselectAll | |||
); | |||
box.append( | |||
controls | |||
); | |||
const list = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-import-list' | |||
); | |||
const localFields = | |||
this.state.config && | |||
this.state.config.fields | |||
? this.state.config.fields | |||
: {}; | |||
names.forEach( | |||
name => { | |||
const existing = | |||
!!localFields[name]; | |||
const field = | |||
fields[name]; | |||
const type = | |||
field && | |||
field.type | |||
? String( | |||
field.type | |||
) | |||
: 'text'; | |||
const label = | |||
field && | |||
field.label | |||
? String( | |||
field.label | |||
) | |||
: this.humanizeName( | |||
name | |||
); | |||
const row = | |||
$('<label>') | |||
.addClass( | |||
'preload-editor-import-row' | |||
) | |||
.attr( | |||
'data-variable', | |||
name | |||
) | |||
.attr( | |||
'data-search', | |||
( | |||
name + | |||
' ' + | |||
label + | |||
' ' + | |||
type | |||
).toLowerCase() | |||
); | |||
const checkbox = | |||
this.checkbox( | |||
false | |||
) | |||
.attr( | |||
'data-import-variable', | |||
name | |||
) | |||
.addClass( | |||
'preload-editor-import-checkbox' | |||
); | |||
const content = | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-import-content' | |||
); | |||
const header = | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-import-header' | |||
); | |||
$('<strong>') | |||
.text(name) | |||
.appendTo(header); | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-import-type' | |||
) | |||
.text(type) | |||
.appendTo(header); | |||
const meta = | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-import-meta' | |||
); | |||
$('<span>') | |||
.text(label) | |||
.appendTo(meta); | |||
const status = | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-import-status' | |||
) | |||
.toggleClass( | |||
'is-existing', | |||
existing | |||
) | |||
.toggleClass( | |||
'is-new', | |||
!existing | |||
) | |||
.text( | |||
existing | |||
? 'REPLACE EXISTING' | |||
: 'NEW' | |||
); | |||
content.append( | |||
header, | |||
meta, | |||
status | |||
); | |||
row.append( | |||
checkbox, | |||
content | |||
); | |||
list.append(row); | |||
} | |||
); | |||
box.append( | |||
list | |||
); | |||
const summary = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-import-summary' | |||
); | |||
const updateSummary = | |||
() => { | |||
const selected = | |||
list.find( | |||
'input[type="checkbox"]:checked' | |||
).length; | |||
const existingSelected = | |||
list.find( | |||
'input[type="checkbox"]:checked' | |||
).filter( | |||
function () { | |||
const name = | |||
$(this) | |||
.attr( | |||
'data-import-variable' | |||
); | |||
return !!( | |||
localFields[ | |||
name | |||
] | |||
); | |||
} | |||
).length; | |||
let text = | |||
selected + | |||
' ' + | |||
( | |||
selected === 1 | |||
? 'variable' | |||
: 'variables' | |||
) + | |||
' selected.'; | |||
if ( | |||
existingSelected | |||
) { | |||
text += | |||
' ' + | |||
existingSelected + | |||
' existing ' + | |||
( | |||
existingSelected === 1 | |||
? 'definition' | |||
: 'definitions' | |||
) + | |||
' will be replaced.'; | |||
} | |||
summary.text( | |||
text | |||
); | |||
importButton.prop( | |||
'disabled', | |||
selected === 0 | |||
); | |||
}; | |||
const filterRows = | |||
() => { | |||
const query = | |||
String( | |||
search.val() || '' | |||
) | |||
.trim() | |||
.toLowerCase(); | |||
list | |||
.find( | |||
'.preload-editor-import-row' | |||
) | |||
.each( | |||
function () { | |||
const row = | |||
$(this); | |||
const haystack = | |||
String( | |||
row.attr( | |||
'data-search' | |||
) || '' | |||
); | |||
row.toggle( | |||
!query || | |||
haystack.includes( | |||
query | |||
) | |||
); | |||
} | |||
); | |||
}; | |||
search.on( | |||
'input.preloadEditor', | |||
filterRows | |||
); | |||
list.on( | |||
'change.preloadEditor', | |||
'input[type="checkbox"]', | |||
updateSummary | |||
); | |||
const buttons = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt-buttons' | |||
); | |||
const cancel = | |||
this.createButton( | |||
'Cancel', | |||
'', | |||
() => { | |||
overlay.remove(); | |||
resolve(null); | |||
} | |||
); | |||
const importButton = | |||
this.createButton( | |||
'Import selected', | |||
'preload-editor-button-primary', | |||
() => { | |||
const selected = | |||
[]; | |||
list | |||
.find( | |||
'input[type="checkbox"]:checked' | |||
) | |||
.each( | |||
function () { | |||
selected.push( | |||
String( | |||
$(this) | |||
.attr( | |||
'data-import-variable' | |||
) | |||
) | |||
); | |||
} | |||
); | |||
overlay.remove(); | |||
resolve(selected); | |||
} | |||
); | |||
buttons.append( | |||
cancel, | |||
importButton | |||
); | |||
box.append( | |||
summary, | |||
buttons | |||
); | |||
overlay.append(box); | |||
$(document.body) | |||
.append(overlay); | |||
overlay.attr( | |||
'tabindex', | |||
'-1' | |||
); | |||
overlay.trigger('focus'); | |||
updateSummary(); | |||
search.trigger('focus'); | |||
const close = | |||
event => { | |||
if ( | |||
event.key === | |||
'Escape' | |||
) { | |||
cancel.trigger( | |||
'click' | |||
); | |||
} | |||
}; | |||
overlay.on( | |||
'keydown.preloadEditor', | |||
close | |||
); | |||
overlay.on( | |||
'click.preloadEditor', | |||
event => { | |||
if ( | |||
event.target === | |||
overlay.get(0) | |||
) { | |||
cancel.trigger( | |||
'click' | |||
); | |||
} | |||
} | |||
); | |||
} | |||
); | |||
}, | |||
/* | |||
* Main import workflow. | |||
*/ | |||
async importVariableDefinitions() { | |||
const source = | |||
await this.createPromptDialog( | |||
'Import variable definitions', | |||
'Enter a preload title or its config.json title.' | |||
); | |||
if (!source) { | |||
return; | |||
} | |||
const configTitle = | |||
this.normalizeConfigTitle( | |||
source | |||
); | |||
this.setStatus( | |||
'Loading source config...', | |||
'loading' | |||
); | |||
try { | |||
const page = | |||
await this.getPage( | |||
configTitle | |||
); | |||
if (!page) { | |||
throw new Error( | |||
'No config.json was found at "' + | |||
configTitle + | |||
'".' | |||
); | |||
} | |||
let sourceConfig; | |||
try { | |||
sourceConfig = | |||
JSON.parse( | |||
page.content | |||
); | |||
} catch (error) { | |||
throw new Error( | |||
'The source config.json contains invalid JSON: ' + | |||
error.message | |||
); | |||
} | |||
if ( | |||
!sourceConfig || | |||
typeof sourceConfig !== 'object' || | |||
Array.isArray(sourceConfig) | |||
) { | |||
throw new Error( | |||
'The source config.json must contain a JSON object.' | |||
); | |||
} | |||
if ( | |||
!sourceConfig.fields || | |||
typeof sourceConfig.fields !== 'object' || | |||
Array.isArray(sourceConfig.fields) | |||
) { | |||
throw new Error( | |||
'The source config.json does not contain a valid "fields" object.' | |||
); | |||
} | |||
const fields = | |||
this.getImportableFields( | |||
sourceConfig | |||
); | |||
const selected = | |||
await this.createImportDefinitionsDialog( | |||
configTitle, | |||
fields | |||
); | |||
if ( | |||
selected === null | |||
) { | |||
this.setStatus( | |||
'Variable import cancelled.', | |||
'warning' | |||
); | |||
return; | |||
} | |||
if (!selected.length) { | |||
this.setStatus( | |||
'No variable definitions were imported.', | |||
'warning' | |||
); | |||
return; | |||
} | |||
if ( | |||
!this.state.config | |||
) { | |||
this.state.config = | |||
this.emptyConfig(); | |||
} | |||
if ( | |||
!this.state.config.fields | |||
) { | |||
this.state.config.fields = {}; | |||
} | |||
const imported = []; | |||
const replaced = []; | |||
selected.forEach( | |||
name => { | |||
if ( | |||
name === 'PAGE' | |||
) { | |||
return; | |||
} | |||
const field = | |||
fields[name]; | |||
if ( | |||
!field | |||
) { | |||
return; | |||
} | |||
if ( | |||
this.state.config.fields[ | |||
name | |||
] | |||
) { | |||
replaced.push( | |||
name | |||
); | |||
} else { | |||
imported.push( | |||
name | |||
); | |||
} | |||
const clone = | |||
this.cloneConfigField( | |||
field | |||
); | |||
if (!clone) { | |||
return; | |||
} | |||
this.state.config.fields[ | |||
name | |||
] = clone; | |||
/* | |||
* If this variable was previously deleted, | |||
* importing its definition explicitly should | |||
* restore it. | |||
*/ | |||
if ( | |||
Array.isArray( | |||
this.state.deletedVariables | |||
) | |||
) { | |||
this.state.deletedVariables = | |||
this.state.deletedVariables | |||
.filter( | |||
deletedName => | |||
String( | |||
deletedName | |||
).toUpperCase() !== | |||
name | |||
); | |||
} | |||
} | |||
); | |||
/* | |||
* Keep PAGE automatic regardless of what the | |||
* imported config contained. | |||
*/ | |||
this.state.config.fields.PAGE = | |||
this.getAutomaticPageField(); | |||
this.markConfigDirty(); | |||
/* | |||
* Keep the current selection when possible. | |||
* Otherwise select the first imported definition. | |||
*/ | |||
const selectedStillExists = | |||
!!( | |||
this.state.config.fields[ | |||
this.state.selectedVariable | |||
] | |||
); | |||
if ( | |||
!selectedStillExists || | |||
this.state.selectedVariable === 'PAGE' | |||
) { | |||
this.state.selectedVariable = | |||
selected[0] || | |||
'PAGE'; | |||
} | |||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.updateEditorTools(); | |||
this.saveDraft(); | |||
let status = | |||
'Imported ' + | |||
selected.length + | |||
' variable ' + | |||
( | |||
selected.length === 1 | |||
? 'definition' | |||
: 'definitions' | |||
) + | |||
'.'; | |||
if ( | |||
replaced.length | |||
) { | |||
status += | |||
' Replaced: ' + | |||
replaced.join(', ') + | |||
'.'; | |||
} | |||
this.setStatus( | |||
status, | |||
'success' | |||
); | |||
mw.notify( | |||
status, | |||
{ | |||
type: 'success' | |||
} | |||
); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor variable import:', | |||
error | |||
); | |||
this.setStatus( | |||
error.message || | |||
'Could not import variable definitions.', | |||
'error' | |||
); | |||
mw.notify( | |||
error.message || | |||
'Could not import variable definitions.', | |||
{ | |||
type: 'error' | |||
} | |||
); | |||
} | |||
}, | |||
// ========================================================= | |||
// Preload parsing | |||
// ========================================================= | |||
extractIncludeonly(source) { | |||
const match = | |||
String(source || '').match( | |||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | |||
return match | |||
? match[1].trim() | |||
: String(source || '').trim(); | |||
}, | |||
findVariables(content) { | |||
const variables = []; | |||
const pattern = | |||
/%%([A-Za-z0-9_-]+)%%/g; | |||
let match; | |||
while ( | |||
(match = pattern.exec(content)) !== null | |||
) { | |||
const name = | |||
match[1].toUpperCase(); | |||
if (!variables.includes(name)) { | |||
variables.push(name); | |||
} | |||
} | |||
return variables; | |||
}, | |||
findConditions(content) { | |||
const conditions = []; | |||
const pattern = | |||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | |||
let match; | |||
while ( | |||
(match = pattern.exec(content)) !== null | |||
) { | |||
conditions.push({ | |||
type: | |||
match[1].toLowerCase(), | |||
expression: | |||
match[2].trim(), | |||
index: | |||
match.index | |||
}); | |||
} | |||
return conditions; | |||
}, | |||
findConditionVariables(content) { | findConditionVariables(content) { | ||
const variables = []; | const variables = []; | ||
| Line 3,557: | Line 4,485: | ||
'Variable configuration' | 'Variable configuration' | ||
); | ); | ||
/* | |||
* Import button lives at the top of the | |||
* variable configuration section so the | |||
* workflow is always easy to find. | |||
*/ | |||
const importDefinitionsButton = | |||
this.createButton( | |||
'Import definitions', | |||
'preload-editor-button-primary preload-editor-import-definitions', | |||
() => | |||
this.importVariableDefinitions() | |||
); | |||
const importHelp = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-help preload-editor-import-definitions-help' | |||
) | |||
.text( | |||
'Import variable definitions from another preload config.json. Existing definitions can be replaced.' | |||
); | |||
const importControls = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-import-definition-controls' | |||
) | |||
.append( | |||
importDefinitionsButton, | |||
importHelp | |||
); | |||
configurationSection.append( | |||
importControls | |||
); | |||
const configurationContainer = | const configurationContainer = | ||
Revision as of 15:14, 21 September 2026
(function (mw, $) {
'use strict';
const PreloadEditor = {
version: '1.1.0',
state: {
preloadTitle: '',
preloadSource: '',
preloadContent: '',
config: null,
variables: [],
conditions: [],
selectedVariable: 'PAGE',
deletedVariables: [],
mode: 'idle',
dirty: false,
dirtyPreload: false,
dirtyConfig: false,
// Local draft state
localDraftSavedAt: null,
localDraftAvailable: false
},
editor: null,
editorView: null,
analysis: null,
root: null,
workspace: null,
status: null,
localDraftStatus: null,
editorTools: null,
draftStorageKey:
'PetitPlanet.PreloadEditor.drafts.v1',
defaults: {
preload:
'<includeonly>\n<!-- preload here -->\n</includeonly>\n<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
},
// =========================================================
// Browser-local draft storage
// =========================================================
getDraftStorage() {
try {
const raw =
window.localStorage.getItem(
this.draftStorageKey
);
if (!raw) {
return {
active: null,
drafts: {}
};
}
const parsed =
JSON.parse(raw);
if (
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
return {
active: null,
drafts: {}
};
}
if (
!parsed.drafts ||
typeof parsed.drafts !== 'object' ||
Array.isArray(parsed.drafts)
) {
parsed.drafts = {};
}
return parsed;
} catch (error) {
console.warn(
'Preload Editor: Could not read local drafts.',
error
);
return {
active: null,
drafts: {}
};
}
},
setDraftStorage(storage) {
try {
window.localStorage.setItem(
this.draftStorageKey,
JSON.stringify(storage)
);
return true;
} catch (error) {
console.warn(
'Preload Editor: Could not save local draft.',
error
);
return false;
}
},
getDraftId(title) {
title =
String(title || '').trim();
return title
? title.toLowerCase()
: '__untitled__';
},
getDraftSnapshot() {
let content =
this.getEditorContent();
if (
!this.editorView &&
!content
) {
content =
this.state.preloadContent ||
'';
}
let config = null;
try {
config =
this.state.config
? JSON.parse(
JSON.stringify(
this.state.config
)
)
: this.emptyConfig();
} catch (error) {
console.warn(
'Preload Editor: Could not clone config for local draft.',
error
);
config =
this.emptyConfig();
}
return {
version: 3,
preloadTitle:
String(
this.state.preloadTitle || ''
),
preloadSource:
String(
this.state.preloadSource || ''
),
preloadContent:
String(
content || ''
),
config,
variables:
Array.isArray(
this.state.variables
)
? this.state.variables.slice()
: [],
conditions:
Array.isArray(
this.state.conditions
)
? this.state.conditions.slice()
: [],
selectedVariable:
String(
this.state.selectedVariable ||
'PAGE'
),
deletedVariables:
Array.isArray(
this.state.deletedVariables
)
? this.state.deletedVariables.slice()
: [],
mode:
this.state.mode ||
'edit',
dirty:
!!this.state.dirty,
dirtyPreload:
!!this.state.dirtyPreload,
dirtyConfig:
!!this.state.dirtyConfig,
savedAt:
Date.now()
};
},
updateLocalDraftStatus(
message,
type
) {
if (!this.localDraftStatus) {
return;
}
this.localDraftStatus.removeClass(
'is-saved',
'is-warning',
'is-error',
'is-empty'
);
if (type) {
this.localDraftStatus.addClass(
'is-' + type
);
}
this.localDraftStatus.text(
message || ''
);
},
formatLocalDraftTime(timestamp) {
if (!timestamp) {
return '';
}
try {
return new Date(
timestamp
).toLocaleTimeString(
[],
{
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}
);
} catch (error) {
return '';
}
},
updateLocalDraftIndicator() {
if (!this.localDraftStatus) {
return;
}
if (
this.state.localDraftSavedAt
) {
const time =
this.formatLocalDraftTime(
this.state.localDraftSavedAt
);
this.updateLocalDraftStatus(
time
? 'Saved locally at ' + time
: 'Saved locally',
'saved'
);
return;
}
this.updateLocalDraftStatus(
'No local draft',
'empty'
);
},
saveDraft() {
if (!this.state.dirty) {
return false;
}
const snapshot =
this.getDraftSnapshot();
const id =
this.getDraftId(
snapshot.preloadTitle
);
const storage =
this.getDraftStorage();
storage.drafts[id] =
snapshot;
storage.active = id;
const saved =
this.setDraftStorage(
storage
);
if (saved) {
this.state.localDraftSavedAt =
snapshot.savedAt;
this.state.localDraftAvailable =
true;
this.updateLocalDraftIndicator();
} else {
this.state.localDraftAvailable =
false;
this.updateLocalDraftStatus(
'Could not save local draft',
'error'
);
}
return saved;
},
clearDraft(title) {
const storage =
this.getDraftStorage();
const id =
this.getDraftId(title);
if (
storage.drafts &&
storage.drafts[id]
) {
delete storage.drafts[id];
}
if (
storage.active === id
) {
storage.active = null;
}
this.setDraftStorage(
storage
);
this.state.localDraftSavedAt =
null;
this.state.localDraftAvailable =
false;
this.updateLocalDraftIndicator();
},
getMostRecentDraft() {
const storage =
this.getDraftStorage();
if (
!storage.drafts ||
!Object.keys(
storage.drafts
).length
) {
return null;
}
if (
storage.active &&
storage.drafts[
storage.active
]
) {
return {
id: storage.active,
draft:
storage.drafts[
storage.active
]
};
}
let latestId = null;
let latestDraft = null;
Object.entries(
storage.drafts
).forEach(
([id, draft]) => {
if (
!draft ||
typeof draft !== 'object'
) {
return;
}
if (
!latestDraft ||
(
Number(
draft.savedAt
) || 0
) >
(
Number(
latestDraft.savedAt
) || 0
)
) {
latestId = id;
latestDraft = draft;
}
}
);
if (!latestDraft) {
return null;
}
return {
id: latestId,
draft: latestDraft
};
},
async restoreDraft() {
const result =
this.getMostRecentDraft();
if (
!result ||
!result.draft
) {
return false;
}
const draft =
result.draft;
const title =
String(
draft.preloadTitle || ''
).trim();
const displayTitle =
title ||
'an untitled preload';
const savedAt =
draft.savedAt
? new Date(
draft.savedAt
).toLocaleString()
: '';
const message =
'Preload Editor found unsaved local work for ' +
displayTitle +
(
savedAt
? '\n\nLast saved locally: ' +
savedAt
: ''
) +
'\n\nRestore it?';
const restore =
await this.createConfirmDialog(
'Restore unsaved work',
message,
'Restore',
'Discard'
);
if (!restore) {
this.clearDraft(title);
return false;
}
let config =
this.emptyConfig();
if (
draft.config &&
typeof draft.config === 'object' &&
!Array.isArray(draft.config)
) {
try {
config =
this.normalizeConfig(
JSON.parse(
JSON.stringify(
draft.config
)
)
);
} catch (error) {
console.warn(
'Preload Editor: Could not restore local config.',
error
);
}
}
const hasDirtyFlags =
typeof draft.dirtyPreload === 'boolean' ||
typeof draft.dirtyConfig === 'boolean';
this.state = {
preloadTitle:
title,
preloadSource:
String(
draft.preloadSource || ''
),
preloadContent:
String(
draft.preloadContent || ''
),
config,
variables:
Array.isArray(
draft.variables
)
? draft.variables.slice()
: [],
conditions:
Array.isArray(
draft.conditions
)
? draft.conditions.slice()
: [],
selectedVariable:
String(
draft.selectedVariable ||
'PAGE'
),
deletedVariables:
Array.isArray(
draft.deletedVariables
)
? draft.deletedVariables
.map(
name =>
String(
name
).toUpperCase()
)
.filter(
name =>
name !==
'PAGE'
)
: [],
mode:
draft.mode ||
'edit',
dirty:
hasDirtyFlags
? !!(
draft.dirtyPreload ||
draft.dirtyConfig
)
: true,
dirtyPreload:
hasDirtyFlags
? !!draft.dirtyPreload
: true,
dirtyConfig:
hasDirtyFlags
? !!draft.dirtyConfig
: true,
localDraftSavedAt:
Number(
draft.savedAt
) || null,
localDraftAvailable:
true
};
this.reanalyze();
this.renderEditor();
this.updateLocalDraftIndicator();
this.setStatus(
'Restored unsaved local work from this browser.',
'warning'
);
mw.notify(
'Unsaved Preload Editor work restored.',
{
type: 'success'
}
);
return true;
},
// =========================================================
// API
// =========================================================
async getPage(title) {
const response =
await new mw.Api().get({
action: 'query',
prop: 'info|revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
});
const page =
response &&
response.query &&
response.query.pages &&
response.query.pages[0];
if (!page || page.missing) {
return null;
}
const content =
page.revisions &&
page.revisions[0] &&
page.revisions[0].slots &&
page.revisions[0].slots.main &&
page.revisions[0].slots.main.content;
return {
title:
page.title ||
title,
content:
typeof content === 'string'
? content
: '',
contentmodel:
page.contentmodel ||
'wikitext'
};
},
async pageExists(title) {
return !!await this.getPage(title);
},
async searchPages(query) {
const response =
await new mw.Api().get({
action: 'query',
list: 'search',
srsearch: query,
srlimit: 10,
srnamespace: 10,
formatversion: 2
});
return (
response &&
response.query &&
response.query.search
) || [];
},
/*
* Save a page.
*
* contentModel is mainly used when creating a page.
*
* For an existing page whose content model needs
* changing, use changeContentModel() first.
*/
async savePage(
title,
content,
summary,
createOnly,
contentModel
) {
const params = {
action: 'edit',
title,
text: content,
summary:
summary ||
'Updated with Preload Editor',
formatversion: 2
};
if (createOnly) {
params.createonly = true;
}
if (contentModel) {
params.contentmodel =
contentModel;
}
return new mw.Api().postWithToken(
'csrf',
params
);
},
/*
* Change the content model of an existing page.
*
* This is the API equivalent of using
* Special:ChangeContentModel manually.
*
* The page must already exist.
*/
async changeContentModel(
title,
model,
summary
) {
return new mw.Api().postWithToken(
'csrf',
{
action:
'changecontentmodel',
title,
model,
reason:
summary ||
'Changed content model with Preload Editor'
}
);
},
/*
* Save config.json and make sure its content model
* is JSON.
*
* New page:
* create directly with contentmodel=json.
*
* Existing page:
* if not already JSON, change its model first,
* then save the JSON normally.
*/
async saveConfigPage(
title,
content,
summary
) {
const page =
await this.getPage(title);
if (!page) {
return this.savePage(
title,
content,
summary,
true,
'json'
);
}
if (
String(
page.contentmodel || ''
).toLowerCase() !== 'json'
) {
await this.changeContentModel(
title,
'json',
'Changed preload config content model to JSON'
);
}
return this.savePage(
title,
content,
summary,
false
);
},
// =========================================================
// Config import helpers
// =========================================================
/*
* Accept either:
*
* Template:Example/Preload
*
* or:
*
* Template:Example/Preload/config.json
*
* and always return the config.json title.
*/
normalizeConfigTitle(title) {
title =
String(title || '').trim();
title =
title.replace(
/\/+$/,
''
);
if (
/\/config\.json$/i.test(
title
)
) {
return title;
}
return title +
'/config.json';
},
/*
* Return a safe clone of a config field.
*
* This is deliberately JSON-based because config
* definitions are JSON-compatible objects.
*/
cloneConfigField(field) {
try {
return JSON.parse(
JSON.stringify(field)
);
} catch (error) {
console.warn(
'Preload Editor: Could not clone imported field.',
error
);
return null;
}
},
/*
* Normalize imported field names to the same uppercase
* convention used throughout the editor.
*/
getImportableFields(sourceConfig) {
const fields =
sourceConfig &&
sourceConfig.fields;
if (
!fields ||
typeof fields !== 'object' ||
Array.isArray(fields)
) {
return {};
}
const result = {};
Object.entries(
fields
).forEach(
([name, field]) => {
const upper =
String(
name || ''
)
.trim()
.toUpperCase();
if (
!upper ||
upper === 'PAGE'
) {
return;
}
if (
!field ||
typeof field !== 'object' ||
Array.isArray(field)
) {
return;
}
const clone =
this.cloneConfigField(
field
);
if (!clone) {
return;
}
result[upper] =
clone;
}
);
return result;
},
/*
* Show the variable-selection dialog after a source
* config has been loaded.
*
* Resolves:
* null -> cancelled
* [] -> confirmed with no selections
* [...] -> selected field names
*/
createImportDefinitionsDialog(
sourceTitle,
fields
) {
return new Promise(
resolve => {
const names =
Object.keys(
fields
).sort(
(a, b) =>
a.localeCompare(
b
)
);
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay preload-editor-import-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt preload-editor-import-dialog'
);
$('<h2>')
.text(
'Import variable definitions'
)
.appendTo(box);
$('<p>')
.text(
'Choose which variable definitions to import from "' +
sourceTitle +
'". Existing local definitions can be replaced.'
)
.appendTo(box);
if (!names.length) {
$('<div>')
.addClass(
'preload-editor-empty preload-editor-import-empty'
)
.text(
'This config.json does not contain any importable variable definitions.'
)
.appendTo(box);
const emptyButtons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const close =
this.createButton(
'Close',
'preload-editor-button-primary',
() => {
overlay.remove();
resolve(null);
}
);
emptyButtons.append(
close
);
box.append(
emptyButtons
);
overlay.append(box);
$(document.body)
.append(overlay);
overlay.attr(
'tabindex',
'-1'
);
overlay.trigger('focus');
return;
}
const search =
this.textInput(
'',
'Search variables...'
)
.addClass(
'preload-editor-import-search'
);
const searchWrap =
$('<div>')
.addClass(
'preload-editor-import-search-wrap'
)
.append(search);
box.append(
searchWrap
);
const controls =
$('<div>')
.addClass(
'preload-editor-import-controls'
);
const selectAll =
this.createButton(
'Select all',
'',
() => {
list
.find(
'input[type="checkbox"]:visible'
)
.prop(
'checked',
true
);
updateSummary();
}
);
const deselectAll =
this.createButton(
'Deselect all',
'',
() => {
list
.find(
'input[type="checkbox"]:visible'
)
.prop(
'checked',
false
);
updateSummary();
}
);
controls.append(
selectAll,
deselectAll
);
box.append(
controls
);
const list =
$('<div>')
.addClass(
'preload-editor-import-list'
);
const localFields =
this.state.config &&
this.state.config.fields
? this.state.config.fields
: {};
names.forEach(
name => {
const existing =
!!localFields[name];
const field =
fields[name];
const type =
field &&
field.type
? String(
field.type
)
: 'text';
const label =
field &&
field.label
? String(
field.label
)
: this.humanizeName(
name
);
const row =
$('<label>')
.addClass(
'preload-editor-import-row'
)
.attr(
'data-variable',
name
)
.attr(
'data-search',
(
name +
' ' +
label +
' ' +
type
).toLowerCase()
);
const checkbox =
this.checkbox(
false
)
.attr(
'data-import-variable',
name
)
.addClass(
'preload-editor-import-checkbox'
);
const content =
$('<span>')
.addClass(
'preload-editor-import-content'
);
const header =
$('<span>')
.addClass(
'preload-editor-import-header'
);
$('<strong>')
.text(name)
.appendTo(header);
$('<span>')
.addClass(
'preload-editor-import-type'
)
.text(type)
.appendTo(header);
const meta =
$('<span>')
.addClass(
'preload-editor-import-meta'
);
$('<span>')
.text(label)
.appendTo(meta);
const status =
$('<span>')
.addClass(
'preload-editor-import-status'
)
.toggleClass(
'is-existing',
existing
)
.toggleClass(
'is-new',
!existing
)
.text(
existing
? 'REPLACE EXISTING'
: 'NEW'
);
content.append(
header,
meta,
status
);
row.append(
checkbox,
content
);
list.append(row);
}
);
box.append(
list
);
const summary =
$('<div>')
.addClass(
'preload-editor-import-summary'
);
const updateSummary =
() => {
const selected =
list.find(
'input[type="checkbox"]:checked'
).length;
const existingSelected =
list.find(
'input[type="checkbox"]:checked'
).filter(
function () {
const name =
$(this)
.attr(
'data-import-variable'
);
return !!(
localFields[
name
]
);
}
).length;
let text =
selected +
' ' +
(
selected === 1
? 'variable'
: 'variables'
) +
' selected.';
if (
existingSelected
) {
text +=
' ' +
existingSelected +
' existing ' +
(
existingSelected === 1
? 'definition'
: 'definitions'
) +
' will be replaced.';
}
summary.text(
text
);
importButton.prop(
'disabled',
selected === 0
);
};
const filterRows =
() => {
const query =
String(
search.val() || ''
)
.trim()
.toLowerCase();
list
.find(
'.preload-editor-import-row'
)
.each(
function () {
const row =
$(this);
const haystack =
String(
row.attr(
'data-search'
) || ''
);
row.toggle(
!query ||
haystack.includes(
query
)
);
}
);
};
search.on(
'input.preloadEditor',
filterRows
);
list.on(
'change.preloadEditor',
'input[type="checkbox"]',
updateSummary
);
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const importButton =
this.createButton(
'Import selected',
'preload-editor-button-primary',
() => {
const selected =
[];
list
.find(
'input[type="checkbox"]:checked'
)
.each(
function () {
selected.push(
String(
$(this)
.attr(
'data-import-variable'
)
)
);
}
);
overlay.remove();
resolve(selected);
}
);
buttons.append(
cancel,
importButton
);
box.append(
summary,
buttons
);
overlay.append(box);
$(document.body)
.append(overlay);
overlay.attr(
'tabindex',
'-1'
);
overlay.trigger('focus');
updateSummary();
search.trigger('focus');
const close =
event => {
if (
event.key ===
'Escape'
) {
cancel.trigger(
'click'
);
}
};
overlay.on(
'keydown.preloadEditor',
close
);
overlay.on(
'click.preloadEditor',
event => {
if (
event.target ===
overlay.get(0)
) {
cancel.trigger(
'click'
);
}
}
);
}
);
},
/*
* Main import workflow.
*/
async importVariableDefinitions() {
const source =
await this.createPromptDialog(
'Import variable definitions',
'Enter a preload title or its config.json title.'
);
if (!source) {
return;
}
const configTitle =
this.normalizeConfigTitle(
source
);
this.setStatus(
'Loading source config...',
'loading'
);
try {
const page =
await this.getPage(
configTitle
);
if (!page) {
throw new Error(
'No config.json was found at "' +
configTitle +
'".'
);
}
let sourceConfig;
try {
sourceConfig =
JSON.parse(
page.content
);
} catch (error) {
throw new Error(
'The source config.json contains invalid JSON: ' +
error.message
);
}
if (
!sourceConfig ||
typeof sourceConfig !== 'object' ||
Array.isArray(sourceConfig)
) {
throw new Error(
'The source config.json must contain a JSON object.'
);
}
if (
!sourceConfig.fields ||
typeof sourceConfig.fields !== 'object' ||
Array.isArray(sourceConfig.fields)
) {
throw new Error(
'The source config.json does not contain a valid "fields" object.'
);
}
const fields =
this.getImportableFields(
sourceConfig
);
const selected =
await this.createImportDefinitionsDialog(
configTitle,
fields
);
if (
selected === null
) {
this.setStatus(
'Variable import cancelled.',
'warning'
);
return;
}
if (!selected.length) {
this.setStatus(
'No variable definitions were imported.',
'warning'
);
return;
}
if (
!this.state.config
) {
this.state.config =
this.emptyConfig();
}
if (
!this.state.config.fields
) {
this.state.config.fields = {};
}
const imported = [];
const replaced = [];
selected.forEach(
name => {
if (
name === 'PAGE'
) {
return;
}
const field =
fields[name];
if (
!field
) {
return;
}
if (
this.state.config.fields[
name
]
) {
replaced.push(
name
);
} else {
imported.push(
name
);
}
const clone =
this.cloneConfigField(
field
);
if (!clone) {
return;
}
this.state.config.fields[
name
] = clone;
/*
* If this variable was previously deleted,
* importing its definition explicitly should
* restore it.
*/
if (
Array.isArray(
this.state.deletedVariables
)
) {
this.state.deletedVariables =
this.state.deletedVariables
.filter(
deletedName =>
String(
deletedName
).toUpperCase() !==
name
);
}
}
);
/*
* Keep PAGE automatic regardless of what the
* imported config contained.
*/
this.state.config.fields.PAGE =
this.getAutomaticPageField();
this.markConfigDirty();
/*
* Keep the current selection when possible.
* Otherwise select the first imported definition.
*/
const selectedStillExists =
!!(
this.state.config.fields[
this.state.selectedVariable
]
);
if (
!selectedStillExists ||
this.state.selectedVariable === 'PAGE'
) {
this.state.selectedVariable =
selected[0] ||
'PAGE';
}
this.reanalyze();
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
this.renderAnalysisBySelector();
this.updateEditorTools();
this.saveDraft();
let status =
'Imported ' +
selected.length +
' variable ' +
(
selected.length === 1
? 'definition'
: 'definitions'
) +
'.';
if (
replaced.length
) {
status +=
' Replaced: ' +
replaced.join(', ') +
'.';
}
this.setStatus(
status,
'success'
);
mw.notify(
status,
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor variable import:',
error
);
this.setStatus(
error.message ||
'Could not import variable definitions.',
'error'
);
mw.notify(
error.message ||
'Could not import variable definitions.',
{
type: 'error'
}
);
}
},
// =========================================================
// Preload parsing
// =========================================================
extractIncludeonly(source) {
const match =
String(source || '').match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
return match
? match[1].trim()
: String(source || '').trim();
},
findVariables(content) {
const variables = [];
const pattern =
/%%([A-Za-z0-9_-]+)%%/g;
let match;
while (
(match = pattern.exec(content)) !== null
) {
const name =
match[1].toUpperCase();
if (!variables.includes(name)) {
variables.push(name);
}
}
return variables;
},
findConditions(content) {
const conditions = [];
const pattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
let match;
while (
(match = pattern.exec(content)) !== null
) {
conditions.push({
type:
match[1].toLowerCase(),
expression:
match[2].trim(),
index:
match.index
});
}
return conditions;
},
findConditionVariables(content) {
const variables = [];
this.findConditions(content).forEach(
condition => {
if (
condition.type !== 'if' &&
condition.type !== 'elseif'
) {
return;
}
const matches =
condition.expression.match(
/[A-Za-z0-9_-]+/g
) || [];
matches.forEach(
name => {
name =
name.toUpperCase();
if (
!variables.includes(
name
)
) {
variables.push(
name
);
}
}
);
}
);
return variables;
},
analyzePreload(content) {
return {
variables:
this.findVariables(content),
conditions:
this.findConditions(content),
conditionVariables:
this.findConditionVariables(
content
)
};
},
// =========================================================
// Config
// =========================================================
getAutomaticPageField() {
return {
label: 'Page Name',
description: 'Name of the page.',
type: 'page',
required: true,
namespace: 'Main',
'allow-new': true
};
},
emptyConfig() {
return {
settings: {
incomplete: 'add-stub'
},
fields: {
PAGE:
this.getAutomaticPageField()
}
};
},
normalizeConfig(config) {
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
config =
this.emptyConfig();
}
if (
!config.settings ||
typeof config.settings !== 'object' ||
Array.isArray(config.settings)
) {
config.settings = {};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
config.fields = {};
}
config.fields.PAGE =
this.getAutomaticPageField();
return config;
},
createDefaultField(
name,
analysis
) {
return {
label:
this.humanizeName(name),
description: '',
type:
analysis.conditionVariables.includes(
name
)
? 'boolean'
: 'text'
};
},
humanizeName(name) {
return String(name || '')
.replace(
/[_-]+/g,
' '
)
.replace(
/([a-z])([A-Z])/g,
'$1 $2'
)
.replace(
/\s+/g,
' '
)
.trim()
.replace(
/\b\w/g,
char =>
char.toUpperCase()
);
},
prepareConfigForVariables(
config,
analysis
) {
config =
this.normalizeConfig(config);
const existing =
config.fields || {};
const deleted =
new Set(
Array.isArray(
this.state.deletedVariables
)
? this.state.deletedVariables
.map(
name =>
String(
name
).toUpperCase()
)
: []
);
const fields = {};
fields.PAGE =
this.getAutomaticPageField();
analysis.variables.forEach(
name => {
if (
name === 'PAGE' ||
deleted.has(name)
) {
return;
}
fields[name] =
existing[name] ||
this.createDefaultField(
name,
analysis
);
}
);
Object.keys(existing).forEach(
name => {
const upper =
name.toUpperCase();
if (
upper !== 'PAGE' &&
!fields[upper] &&
!deleted.has(upper)
) {
fields[upper] =
existing[name];
}
}
);
config.fields = fields;
return config;
},
validateConfig(
config,
analysis
) {
const errors = [];
const warnings = [];
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
return {
valid: false,
errors: [
'Config must be an object.'
],
warnings: []
};
}
if (
!config.fields ||
typeof config.fields !== 'object' ||
Array.isArray(config.fields)
) {
return {
valid: false,
errors: [
'"fields" must be an object.'
],
warnings
};
}
const configured =
Object.keys(
config.fields
).map(
name =>
name.toUpperCase()
);
analysis.variables.forEach(
name => {
if (
!configured.includes(name)
) {
errors.push(
'Variable "' +
name +
'" is not configured.'
);
}
}
);
configured.forEach(
name => {
if (
!analysis.variables.includes(
name
)
) {
warnings.push(
'Field "' +
name +
'" is not used by the preload.'
);
}
}
);
if (
!configured.includes('PAGE')
) {
errors.push(
'PAGE must always be configured.'
);
}
Object.entries(
config.fields
).forEach(
([name, field]) => {
if (
!field ||
typeof field !== 'object' ||
Array.isArray(field)
) {
errors.push(
'Field "' +
name +
'" must be an object.'
);
return;
}
if (!field.type) {
warnings.push(
'Field "' +
name +
'" has no type.'
);
}
if (
(
field.type === 'select' ||
field.type === 'multiselect'
) &&
!field.source &&
!Array.isArray(field.options)
) {
warnings.push(
'Field "' +
name +
'" is a ' +
field.type +
' without a source or manual options.'
);
}
}
);
return {
valid:
!errors.length,
errors,
warnings
};
},
// =========================================================
// Transformation helpers
// =========================================================
transformationObject(
transform,
prefix,
suffix,
from,
to
) {
if (!transform) {
return null;
}
switch (transform) {
case 'remove-prefix':
return prefix
? {
transform,
prefix
}
: null;
case 'remove-suffix':
return suffix
? {
transform,
suffix
}
: null;
case 'replace':
return from === ''
? null
: {
transform,
from,
to: to || ''
};
default:
return null;
}
},
// =========================================================
// UI helpers
// =========================================================
createSection(
title,
className
) {
const section =
$('<section>')
.addClass(
'preload-editor-section'
);
if (className) {
section.addClass(className);
}
$('<h2>')
.addClass(
'preload-editor-section-title'
)
.text(title)
.appendTo(section);
return section;
},
createButton(
label,
className,
callback
) {
const button =
$('<button>')
.attr(
'type',
'button'
)
.addClass(
'preload-editor-button'
)
.text(label);
if (className) {
button.addClass(className);
}
if (callback) {
button.on(
'click.preloadEditor',
callback
);
}
return button;
},
fieldRow(
label,
input,
description
) {
const row =
$('<div>')
.addClass(
'preload-editor-field'
);
$('<label>')
.addClass(
'preload-editor-label'
)
.text(label)
.appendTo(row);
if (description) {
$('<div>')
.addClass(
'preload-editor-help'
)
.text(description)
.appendTo(row);
}
row.append(input);
return row;
},
textInput(
value,
placeholder
) {
return $('<input>')
.attr({
type: 'text',
value:
value == null
? ''
: value,
placeholder:
placeholder || ''
})
.addClass(
'preload-editor-input'
);
},
textarea(value) {
return $('<textarea>')
.val(
value == null
? ''
: value
)
.addClass(
'preload-editor-textarea'
);
},
checkbox(checked) {
return $('<input>')
.attr(
'type',
'checkbox'
)
.prop(
'checked',
!!checked
)
.addClass(
'preload-editor-checkbox'
);
},
select(
options,
value
) {
const select =
$('<select>')
.addClass(
'preload-editor-select'
);
options.forEach(
option => {
$('<option>')
.attr(
'value',
option.value
)
.text(
option.label
)
.prop(
'selected',
String(
option.value
) ===
String(
value === undefined
? ''
: value
)
)
.appendTo(select);
}
);
return select;
},
// =========================================================
// In-wiki dialogs
// =========================================================
createConfirmDialog(
title,
message,
confirmLabel,
cancelLabel
) {
return new Promise(
resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt'
);
$('<h2>')
.text(title)
.appendTo(box);
const messageLines =
String(message || '')
.split('\n');
messageLines.forEach(
line => {
if (line === '') {
$('<div>')
.addClass(
'preload-editor-prompt-spacer'
)
.appendTo(box);
return;
}
$('<p>')
.text(line)
.appendTo(box);
}
);
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
cancelLabel || 'Cancel',
'',
() => {
overlay.remove();
resolve(false);
}
);
const confirm =
this.createButton(
confirmLabel || 'Confirm',
'preload-editor-button-primary',
() => {
overlay.remove();
resolve(true);
}
);
buttons.append(
cancel,
confirm
);
box.append(buttons);
overlay.append(box);
$(document.body)
.append(overlay);
const close =
value => {
if (value === 'Escape') {
cancel.trigger('click');
}
};
overlay.on(
'keydown.preloadEditor',
event => {
close(event.key);
}
);
overlay.attr(
'tabindex',
'-1'
);
overlay.trigger('focus');
overlay.on(
'click.preloadEditor',
event => {
if (
event.target ===
overlay.get(0)
) {
cancel.trigger('click');
}
}
);
}
);
},
createPromptDialog(
title,
message
) {
return new Promise(
resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt'
);
$('<h2>')
.text(title)
.appendTo(box);
$('<p>')
.text(message)
.appendTo(box);
const input =
this.textInput();
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const confirm =
this.createButton(
'Load',
'preload-editor-button-primary',
() => {
const value =
String(
input.val() || ''
).trim();
if (!value) {
return;
}
overlay.remove();
resolve(value);
}
);
buttons.append(
cancel,
confirm
);
box.append(
input,
buttons
);
overlay.append(box);
$(document.body)
.append(overlay);
input.trigger('focus');
input.on(
'keydown.preloadEditor',
event => {
if (
event.key === 'Enter'
) {
confirm.trigger('click');
}
if (
event.key === 'Escape'
) {
cancel.trigger('click');
}
}
);
}
);
},
createConfigReviewDialog() {
return new Promise(
resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay preload-editor-code-review-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt preload-editor-code-review'
);
$('<h2>')
.text('Review config.json')
.appendTo(box);
$('<p>')
.text(
'This is the config.json that will be saved for the current preload.'
)
.appendTo(box);
const code =
$('<pre>')
.addClass(
'preload-editor-config-review'
)
.text(
this.getConfigJSON()
);
const codeWrap =
$('<div>')
.addClass(
'preload-editor-config-review-wrap'
)
.append(code);
box.append(codeWrap);
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const close =
this.createButton(
'Close',
'preload-editor-button-primary',
() => {
overlay.remove();
resolve();
}
);
buttons.append(close);
box.append(buttons);
overlay.append(box);
$(document.body)
.append(overlay);
overlay.attr(
'tabindex',
'-1'
);
overlay.trigger('focus');
overlay.on(
'keydown.preloadEditor',
event => {
if (
event.key === 'Escape'
) {
close.trigger('click');
}
}
);
overlay.on(
'click.preloadEditor',
event => {
if (
event.target ===
overlay.get(0)
) {
close.trigger('click');
}
}
);
}
);
},
async confirmSave(
mode,
title
) {
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
let saveDescription;
if (mode === 'preload') {
saveDescription =
'Save the preload page only?';
} else if (mode === 'config') {
saveDescription =
'Save the config.json page only?';
} else {
saveDescription =
'Save both the preload and config.json?';
}
let pages = [];
if (
mode === 'preload' ||
mode === 'both'
) {
pages.push(
'Preload: ' + title
);
}
if (
mode === 'config' ||
mode === 'both'
) {
pages.push(
'Config: ' + configTitle
);
pages.push(
'Config content model: JSON'
);
}
const message =
saveDescription +
'\n\n' +
pages.join('\n') +
'\n\nThis will write the changes to the wiki.';
return this.createConfirmDialog(
'Confirm save',
message,
'Save',
'Cancel'
);
},
// =========================================================
// CodeMirror
// =========================================================
destroyEditor() {
if (!this.editor) {
this.editorView = null;
this.editorTools = null;
return;
}
const {
view,
inputHandler
} = this.editor;
if (
view &&
inputHandler
) {
view.dom.removeEventListener(
'input',
inputHandler
);
}
if (view) {
view.destroy();
}
this.editor = null;
this.editorView = null;
this.editorTools = null;
},
getEditorView() {
return this.editorView;
},
getEditorContent() {
if (
this.editorView &&
this.editorView.state
) {
return this.editorView.state.doc.toString();
}
return (
this.state.preloadContent || ''
);
},
setEditorContent(content) {
content =
String(content || '');
if (!this.editorView) {
this.state.preloadContent =
content;
return;
}
const current =
this.editorView.state.doc.toString();
if (current === content) {
return;
}
this.editorView.dispatch({
changes: {
from: 0,
to:
this.editorView.state.doc.length,
insert: content
}
});
},
getEditorSelection() {
if (
!this.editorView ||
!this.editorView.state
) {
return null;
}
const selection =
this.editorView.state
.selection.main;
return {
from:
selection.from,
to:
selection.to,
text:
this.editorView.state.doc.sliceString(
selection.from,
selection.to
)
};
},
focusEditor() {
if (this.editorView) {
this.editorView.focus();
}
},
insertEditorText(
text,
selectionFrom,
selectionTo,
cursorPosition
) {
if (!this.editorView) {
return;
}
const view =
this.editorView;
const selection =
view.state.selection.main;
const from =
selectionFrom === undefined
? selection.from
: selectionFrom;
const to =
selectionTo === undefined
? selection.to
: selectionTo;
const cursor =
cursorPosition === undefined
? from + text.length
: cursorPosition;
view.dispatch({
changes: {
from,
to,
insert: text
},
selection: {
anchor: cursor
}
});
view.focus();
},
// =========================================================
// Editor tools
// =========================================================
wrapSelectionWithVariable() {
const selected =
this.getEditorSelection();
if (
!selected ||
selected.from === selected.to
) {
this.focusEditor();
return;
}
const replacement =
'%%' +
selected.text +
'%%';
this.insertEditorText(
replacement,
selected.from,
selected.to,
selected.from +
replacement.length
);
},
insertVariable(name) {
name =
String(name || '')
.trim()
.toUpperCase();
if (!name) {
return;
}
const selected =
this.getEditorSelection();
if (!selected) {
return;
}
this.insertEditorText(
'%%' + name + '%%',
selected.from,
selected.to
);
},
insertCondition(
type,
variable
) {
type =
String(type || '')
.toLowerCase();
variable =
String(variable || '')
.trim()
.toUpperCase();
const selected =
this.getEditorSelection();
if (!selected) {
return;
}
let text;
let cursorOffset;
switch (type) {
case 'if': {
if (!variable) {
return;
}
const opening =
'<!-- @if ' +
variable +
' -->';
const closing =
'<!-- @endif -->';
if (
selected.from !==
selected.to
) {
text =
opening +
'\n' +
selected.text +
'\n' +
closing;
cursorOffset =
text.length;
} else {
text =
opening +
'\n\n' +
closing;
cursorOffset =
opening.length + 1;
}
break;
}
case 'elseif':
text =
'<!-- @elseif -->';
cursorOffset =
text.length;
break;
case 'else':
text =
'<!-- @else -->';
cursorOffset =
text.length;
break;
case 'endif':
text =
'<!-- @endif -->';
cursorOffset =
text.length;
break;
default:
return;
}
this.insertEditorText(
text,
selected.from,
selected.to,
selected.from +
cursorOffset
);
},
getToolVariables() {
const variables =
new Set(['PAGE']);
if (
this.analysis &&
this.analysis.variables
) {
this.analysis.variables.forEach(
name => {
variables.add(
String(
name
).toUpperCase()
);
}
);
}
return Array.from(variables);
},
createToolSelect(
options,
className
) {
const select =
$('<select>')
.addClass(
'preload-editor-tool-select'
);
if (className) {
select.addClass(className);
}
options.forEach(
option => {
$('<option>')
.attr(
'value',
option.value
)
.text(option.label)
.appendTo(select);
}
);
return select;
},
updateEditorToolVariables() {
if (!this.editorTools) {
return;
}
const variables =
this.getToolVariables();
const current =
this.editorTools
.variableSelect
.val();
const currentCondition =
this.editorTools
.conditionVariableSelect
.val();
const populate =
(
select,
value
) => {
select.empty();
variables.forEach(
name => {
$('<option>')
.attr(
'value',
name
)
.text(name)
.appendTo(select);
}
);
if (
value &&
variables.includes(value)
) {
select.val(value);
} else if (
variables.length
) {
select.val(
variables[0]
);
}
};
populate(
this.editorTools.variableSelect,
current
);
populate(
this.editorTools.conditionVariableSelect,
currentCondition
);
},
updateConditionVariableVisibility() {
if (!this.editorTools) {
return;
}
const type =
this.editorTools
.conditionTypeSelect
.val();
this.editorTools
.conditionVariableWrap
.toggle(
type === 'if'
);
},
renderEditorTools() {
const tools =
$('<div>')
.addClass(
'preload-editor-tools'
);
const wrapGroup =
$('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-wrap'
);
wrapGroup.append(
this.createButton(
'Wrap selection',
'preload-editor-tool-button',
() =>
this.wrapSelectionWithVariable()
)
);
const variableGroup =
$('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-variable'
);
$('<span>')
.addClass(
'preload-editor-tool-label'
)
.text(
'Insert existing variable'
)
.appendTo(variableGroup);
const variableSelect =
this.createToolSelect(
[],
'preload-editor-variable-select'
);
variableGroup.append(
variableSelect,
this.createButton(
'Insert',
'preload-editor-tool-button',
() =>
this.insertVariable(
variableSelect.val()
)
)
);
const conditionGroup =
$('<div>')
.addClass(
'preload-editor-tool-group preload-editor-tool-group-condition'
);
$('<span>')
.addClass(
'preload-editor-tool-label'
)
.text('Insert condition')
.appendTo(conditionGroup);
const conditionTypeSelect =
this.createToolSelect(
[
{
value: 'if',
label: '@if'
},
{
value: 'elseif',
label: '@elseif'
},
{
value: 'else',
label: '@else'
},
{
value: 'endif',
label: '@endif'
}
],
'preload-editor-condition-type-select'
);
const conditionVariableSelect =
this.createToolSelect(
[],
'preload-editor-condition-variable-select'
);
const conditionVariableWrap =
$('<span>')
.addClass(
'preload-editor-condition-variable'
)
.append(
conditionVariableSelect
);
const insertCondition =
this.createButton(
'Insert',
'preload-editor-tool-button',
() =>
this.insertCondition(
conditionTypeSelect.val(),
conditionVariableSelect.val()
)
);
conditionTypeSelect.on(
'change.preloadEditor',
() =>
this.updateConditionVariableVisibility()
);
conditionGroup.append(
conditionTypeSelect,
conditionVariableWrap,
insertCondition
);
tools.append(
wrapGroup,
variableGroup,
conditionGroup
);
this.editorTools = {
root: tools,
variableSelect,
conditionTypeSelect,
conditionVariableSelect,
conditionVariableWrap,
insertCondition
};
this.updateEditorToolVariables();
this.updateConditionVariableVisibility();
return tools;
},
updateEditorTools() {
this.updateEditorToolVariables();
this.updateConditionVariableVisibility();
},
// =========================================================
// Syntax highlighting
// =========================================================
getKnownVariables() {
const known =
new Set(['PAGE']);
const fields =
this.state.config &&
this.state.config.fields;
if (
fields &&
typeof fields === 'object'
) {
Object.keys(fields).forEach(
name => {
known.add(
String(
name
).toUpperCase()
);
}
);
}
return known;
},
createSyntaxDecorations(
state,
Decoration
) {
const decorations = [];
const source =
state.doc.toString();
const knownVariables =
this.getKnownVariables();
const variablePattern =
/%%([A-Za-z0-9_-]+)%%/g;
let match;
while (
(
match =
variablePattern.exec(
source
)
) !== null
) {
const name =
match[1].toUpperCase();
const known =
knownVariables.has(name);
decorations.push(
Decoration.mark({
class: [
'preload-editor-syntax-variable',
known
? 'preload-editor-syntax-variable-known'
: 'preload-editor-syntax-variable-unknown'
].join(' ')
}).range(
match.index,
match.index +
match[0].length
)
);
}
const conditionPattern =
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi;
while (
(
match =
conditionPattern.exec(
source
)
) !== null
) {
decorations.push(
Decoration.mark({
class:
'preload-editor-syntax-condition'
}).range(
match.index,
match.index +
match[0].length
)
);
const directiveStart =
match.index +
match[0].indexOf('@');
decorations.push(
Decoration.mark({
class:
'preload-editor-syntax-directive'
}).range(
directiveStart,
directiveStart +
match[1].length +
1
)
);
const type =
match[1].toLowerCase();
if (
type !== 'if' &&
type !== 'elseif'
) {
continue;
}
const expression =
match[2];
const expressionStart =
match.index +
match[0].indexOf(
expression
);
const expressionPattern =
/[A-Za-z0-9_-]+/g;
let expressionMatch;
while (
(
expressionMatch =
expressionPattern.exec(
expression
)
) !== null
) {
const name =
expressionMatch[0]
.toUpperCase();
if (
!knownVariables.has(name)
) {
continue;
}
const start =
expressionStart +
expressionMatch.index;
decorations.push(
Decoration.mark({
class:
'preload-editor-syntax-condition-variable'
}).range(
start,
start +
expressionMatch[0]
.length
)
);
}
}
return Decoration.set(
decorations,
true
);
},
async installSyntaxHighlighting(view) {
await mw.loader.using(
'ext.CodeMirror.v6.lib'
);
const require =
mw.loader.require;
const {
StateField,
StateEffect,
EditorView,
Decoration
} = require(
'ext.CodeMirror.v6.lib'
);
const syntaxField =
StateField.define({
create: state =>
this.createSyntaxDecorations(
state,
Decoration
),
update: (
decorations,
transaction
) =>
transaction.docChanged
? this.createSyntaxDecorations(
transaction.state,
Decoration
)
: decorations,
provide: field =>
EditorView.decorations.from(
field
)
});
view.dispatch({
effects:
StateEffect.appendConfig.of(
syntaxField
)
});
this.editor.syntaxField =
syntaxField;
},
async mountCodeMirror(textarea) {
return new Promise(
(
resolve,
reject
) => {
let settled = false;
const cleanup =
() => {
mw.hook(
'dev.CCM.ready'
).remove(onReady);
mw.hook(
'dev.CCM.load'
).remove(onLoad);
};
const onReady =
async cm => {
if (
settled ||
!cm ||
cm.textarea !== textarea ||
!cm.view
) {
return;
}
settled = true;
cleanup();
const view =
cm.view;
const inputHandler =
() => {
this.state
.preloadContent =
view.state.doc.toString();
this.markPreloadDirty();
this.reanalyze();
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
this.renderAnalysisBySelector();
this.updateEditorTools();
this.saveDraft();
};
view.dom.addEventListener(
'input',
inputHandler
);
view.dom.classList.add(
'preload-editor-codemirror'
);
const wrapper =
view.dom.closest(
'.ext-codemirror-wrapper'
);
if (wrapper) {
wrapper.classList.add(
'preload-editor-codemirror-wrapper'
);
}
this.editor = {
cm,
view,
textarea,
inputHandler
};
this.editorView =
view;
this.state.preloadContent =
view.state.doc.toString();
try {
await this.installSyntaxHighlighting(
view
);
if (
this.state.dirty
) {
this.saveDraft();
}
resolve(view);
} catch (error) {
reject(error);
}
};
const onLoad =
loader => {
if (
settled ||
typeof loader !==
'function'
) {
return;
}
try {
loader(textarea);
} catch (error) {
settled = true;
cleanup();
reject(error);
}
};
mw.hook(
'dev.CCM.ready'
).add(onReady);
mw.hook(
'dev.CCM.load'
).add(onLoad);
}
);
},
// =========================================================
// Main UI
// =========================================================
init() {
const root =
$('#mw-content-text');
if (!root.length) {
return;
}
this.root =
$('<div>')
.attr(
'id',
'preload-editor'
)
.addClass(
'preload-editor'
);
root.append(this.root);
this.renderShell();
this.restoreDraft();
window.addEventListener(
'beforeunload',
() => {
if (this.state.dirty) {
this.saveDraft();
}
}
);
},
renderShell() {
this.destroyEditor();
this.root.empty();
const loader =
this.createSection('Preload');
const titleInput =
this.textInput(
this.state.preloadTitle,
'Template:Example/Preload'
).attr(
'id',
'preload-editor-title-input'
);
titleInput.on(
'input.preloadEditor',
() => {
this.state.preloadTitle =
String(
titleInput.val() || ''
).trim();
if (
this.state.dirty &&
this.state.mode === 'new'
) {
this.saveDraft();
}
}
);
const loadButton =
this.createButton(
'Load',
'preload-editor-button-primary',
() =>
this.loadExisting(
titleInput.val()
)
);
const newButton =
this.createButton(
'New',
'',
() => this.startNew()
);
const sampleButton =
this.createButton(
'Load Sample Page',
'',
() => this.loadSamplePage()
);
loader.append(
$('<div>')
.addClass(
'preload-editor-toolbar'
)
.append(
titleInput,
loadButton,
newButton,
sampleButton
)
);
this.root.append(loader);
this.workspace =
$('<div>')
.addClass(
'preload-editor-workspace'
);
this.root.append(
this.workspace
);
/*
* Fixed footer
*
* Local draft status, normal status messages,
* and save actions live here.
*/
const footer =
$('<div>')
.addClass(
'preload-editor-footer'
);
this.localDraftStatus =
$('<div>')
.addClass(
'preload-editor-local-draft-status is-empty'
)
.text(
'No local draft'
);
this.status =
$('<div>')
.addClass(
'preload-editor-status'
);
footer.append(
this.localDraftStatus,
this.status
);
this.root.append(
footer
);
this.updateLocalDraftIndicator();
this.renderWelcome();
},
renderWelcome() {
this.workspace.empty();
const section =
this.createSection('Get started');
$('<p>')
.text(
'Load an existing preload, create a new one, or start from a sample wiki page.'
)
.appendTo(section);
const tips =
$('<ul>')
.addClass(
'preload-editor-tips'
);
[
'Existing preload: loads the preload and its config.json if available.',
'New preload: starts with the standard Preload Template structure.',
'Sample page: loads normal wiki page content so you can turn it into a preload.'
].forEach(
text => {
$('<li>')
.text(text)
.appendTo(tips);
}
);
section.append(tips);
this.workspace.append(section);
},
setStatus(
message,
type
) {
if (!this.status) {
return;
}
this.status.removeClass(
'is-error',
'is-success',
'is-warning',
'is-loading'
);
if (type) {
this.status.addClass(
'is-' + type
);
}
this.status.text(
message || ''
);
},
// =========================================================
// Load / create
// =========================================================
async loadExisting(title) {
title =
String(title || '').trim();
if (!title) {
mw.notify(
'Enter a preload page title first.',
{
type: 'warn'
}
);
return;
}
this.setStatus(
'Loading preload...',
'loading'
);
try {
const page =
await this.getPage(title);
if (!page) {
const suggestions =
await this.findSimilarPreloads(
title
);
this.renderMissingPage(
title,
suggestions
);
this.setStatus(
'Preload not found.',
'warning'
);
return;
}
await this.loadDefinition(
page.title,
page.content
);
this.setStatus(
'Preload loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor:',
error
);
this.setStatus(
error.message ||
'Could not load preload.',
'error'
);
}
},
async findSimilarPreloads(title) {
let query =
title
.replace(
/\/Preload$/i,
''
)
.trim();
if (!query) {
query = title;
}
try {
const results =
await this.searchPages(query);
return results.filter(
result =>
/\/Preload$/i.test(
result.title
)
);
} catch (error) {
console.error(
'Preload Editor search:',
error
);
return [];
}
},
renderMissingPage(
title,
suggestions
) {
this.workspace.empty();
const section =
this.createSection(
'Preload not found'
);
$('<p>')
.text(
'No preload exists at "' +
title +
'".'
)
.appendTo(section);
if (suggestions.length) {
$('<h3>')
.text('Similar preloads')
.appendTo(section);
const list =
$('<div>')
.addClass(
'preload-editor-suggestions'
);
suggestions.forEach(
result => {
list.append(
this.createButton(
result.title,
'preload-editor-suggestion',
() =>
this.loadExisting(
result.title
)
)
);
}
);
section.append(list);
}
section.append(
this.createButton(
'Create this preload',
'preload-editor-button-primary',
() =>
this.startNew(title)
)
);
this.workspace.append(section);
},
async startNew(title) {
title =
String(title || '').trim();
this.state = {
preloadTitle: title,
preloadSource: '',
preloadContent:
this.defaults.preload,
config:
this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
selectedVariable: 'PAGE',
deletedVariables: [],
mode: 'new',
dirty: true,
dirtyPreload: true,
dirtyConfig: true,
localDraftSavedAt: null,
localDraftAvailable: false
};
this.saveDraft();
this.renderEditor();
this.setStatus(
'Creating a new preload.',
'success'
);
},
async loadSamplePage() {
const result =
await this.createPromptDialog(
'Load Sample Page',
'Enter the title of a normal wiki page to use as the starting point.'
);
if (!result) {
return;
}
this.setStatus(
'Loading sample page...',
'loading'
);
try {
const page =
await this.getPage(result);
if (!page) {
throw new Error(
'Sample page not found: ' +
result
);
}
const sample =
this.convertSampleToPreload(
page.content
);
this.state = {
preloadTitle: '',
preloadSource: '',
preloadContent: sample,
config:
this.emptyConfig(),
variables: ['PAGE'],
conditions: [],
selectedVariable: 'PAGE',
deletedVariables: [],
mode: 'new',
dirty: true,
dirtyPreload: true,
dirtyConfig: true,
localDraftSavedAt: null,
localDraftAvailable: false
};
this.reanalyze();
this.saveDraft();
this.renderEditor();
this.setStatus(
'Sample page loaded.',
'success'
);
} catch (error) {
console.error(
'Preload Editor sample:',
error
);
this.setStatus(
error.message ||
'Could not load sample page.',
'error'
);
}
},
convertSampleToPreload(source) {
let content =
String(source || '').trim();
const include =
content.match(
/<includeonly>([\s\S]*?)<\/includeonly>/i
);
if (include) {
content =
include[1].trim();
}
content =
content
.replace(
/<noinclude>[\s\S]*?<\/noinclude>/gi,
''
)
.trim();
return (
'<includeonly>\n' +
content +
'\n</includeonly>\n' +
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n'
);
},
async loadDefinition(
title,
source
) {
const content =
this.extractIncludeonly(source);
let config =
this.emptyConfig();
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
const configPage =
await this.getPage(configTitle);
if (configPage) {
try {
config =
JSON.parse(
configPage.content
);
} catch (error) {
throw new Error(
'The existing config.json contains invalid JSON: ' +
error.message
);
}
}
const analysis =
this.analyzePreload(content);
this.state.deletedVariables = [];
config =
this.prepareConfigForVariables(
config,
analysis
);
this.state = {
preloadTitle: title,
preloadSource: source,
preloadContent: source,
config,
variables:
analysis.variables,
conditions:
analysis.conditions,
selectedVariable:
analysis.variables.includes('PAGE')
? 'PAGE'
: (
analysis.variables[0] ||
'PAGE'
),
deletedVariables: [],
mode: 'edit',
dirty: false,
dirtyPreload: false,
dirtyConfig: false,
localDraftSavedAt: null,
localDraftAvailable: false
};
this.clearDraft(title);
this.renderEditor();
},
// =========================================================
// Editor
// =========================================================
renderEditor() {
this.destroyEditor();
this.workspace.empty();
const layout =
$('<div>')
.addClass(
'preload-editor-layout'
);
const left =
$('<div>')
.addClass(
'preload-editor-left'
);
const right =
$('<div>')
.addClass(
'preload-editor-right'
);
const editorSection =
this.createSection(
'Preload',
'preload-editor-main-section'
);
const meta =
$('<div>')
.addClass(
'preload-editor-meta'
);
$('<strong>')
.text(
this.state.preloadTitle ||
'New preload'
)
.appendTo(meta);
if (
this.state.mode === 'new'
) {
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('NEW')
.appendTo(meta);
}
editorSection.append(meta);
editorSection.append(
this.renderEditorTools()
);
const textarea =
this.textarea(
this.state.preloadContent
)
.attr(
'id',
'preload-editor-content'
)
.addClass(
'preload-editor-content'
);
const editorMount =
$('<div>')
.addClass(
'preload-editor-codemirror-mount'
)
.append(textarea);
editorSection.append(editorMount);
left.append(editorSection);
const analysisSection =
this.createSection('Analysis');
const analysisContainer =
$('<div>')
.addClass(
'preload-editor-analysis'
);
analysisSection.append(
analysisContainer
);
const variablesSection =
this.createSection('Variables');
const detectVariablesButton =
this.createButton(
'Detect variables',
'preload-editor-button-primary preload-editor-detect-variables',
() =>
this.detectVariables()
);
const detectHelp =
$('<div>')
.addClass(
'preload-editor-help preload-editor-detect-variables-help'
)
.text(
'Refresh the variable list after editing the preload.'
);
variablesSection.append(
$('<div>')
.addClass(
'preload-editor-variable-detect'
)
.append(
detectVariablesButton,
detectHelp
)
);
const variableContainer =
$('<div>')
.addClass(
'preload-editor-variables'
);
variablesSection.append(
variableContainer
);
const configurationSection =
this.createSection(
'Variable configuration'
);
/*
* Import button lives at the top of the
* variable configuration section so the
* workflow is always easy to find.
*/
const importDefinitionsButton =
this.createButton(
'Import definitions',
'preload-editor-button-primary preload-editor-import-definitions',
() =>
this.importVariableDefinitions()
);
const importHelp =
$('<div>')
.addClass(
'preload-editor-help preload-editor-import-definitions-help'
)
.text(
'Import variable definitions from another preload config.json. Existing definitions can be replaced.'
);
const importControls =
$('<div>')
.addClass(
'preload-editor-import-definition-controls'
)
.append(
importDefinitionsButton,
importHelp
);
configurationSection.append(
importControls
);
const configurationContainer =
$('<div>')
.addClass(
'preload-editor-variable-configuration'
);
configurationSection.append(
configurationContainer
);
const configSection =
this.createSection(
'Configuration'
);
const configHelp =
$('<div>')
.addClass(
'preload-editor-help'
)
.text(
'Review the exact config.json that will be uploaded to the wiki.'
);
const reviewConfigButton =
this.createButton(
'Review config.json',
'',
() =>
this.createConfigReviewDialog()
);
configSection.append(
configHelp,
reviewConfigButton
);
right.append(
analysisSection,
variablesSection,
configurationSection,
configSection
);
layout.append(
left,
right
);
const saveBar =
$('<div>')
.addClass(
'preload-editor-save-bar'
);
saveBar.append(
this.createButton(
'Save Preload',
'',
() =>
this.savePreloadOnly()
),
this.createButton(
'Save Config',
'',
() =>
this.saveConfigOnly()
),
this.createButton(
'Save',
'preload-editor-button-primary',
() =>
this.saveAll()
)
);
this.root
.find('.preload-editor-footer')
.append(saveBar);
this.workspace.append(
layout
);
this.reanalyze();
this.renderVariables(
variableContainer
);
this.renderAnalysis(
analysisContainer
);
this.renderVariableConfiguration(
configurationContainer
);
this.updateEditorTools();
this.updateLocalDraftIndicator();
this.mountCodeMirror(
textarea.get(0)
).catch(
error => {
console.error(
'Preload Editor CodeMirror:',
error
);
this.setStatus(
error.message ||
'Could not initialize CodeMirror.',
'error'
);
}
);
},
// =========================================================
// Variable detection
// =========================================================
detectVariables() {
const source =
this.getEditorContent();
this.state.preloadContent =
source;
const content =
this.extractIncludeonly(
source
);
const analysis =
this.analyzePreload(
content
);
/*
* If a variable was previously deleted from
* the configuration, detecting the current
* preload again should allow it to come back.
*/
if (
Array.isArray(
this.state.deletedVariables
)
) {
const detected =
new Set(
analysis.variables
.map(
name =>
String(
name
).toUpperCase()
)
);
this.state.deletedVariables =
this.state.deletedVariables
.filter(
name =>
!detected.has(
String(
name
).toUpperCase()
)
);
} else {
this.state.deletedVariables = [];
}
this.markDirty();
this.reanalyze();
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
this.renderAnalysisBySelector();
this.updateEditorTools();
this.saveDraft();
this.setStatus(
'Variables detected from the current preload.',
'success'
);
},
reanalyze() {
const source =
this.getEditorContent();
const content =
this.extractIncludeonly(source);
const analysis =
this.analyzePreload(
content
);
this.state.preloadContent =
source;
this.state.variables =
analysis.variables;
this.state.conditions =
analysis.conditions;
this.state.config =
this.prepareConfigForVariables(
this.state.config ||
this.emptyConfig(),
analysis
);
this.analysis =
analysis;
const available =
['PAGE'].concat(
analysis.variables.filter(
name =>
name !== 'PAGE'
)
);
if (
!available.includes(
this.state.selectedVariable
)
) {
this.state.selectedVariable =
available[0] ||
'PAGE';
}
},
// =========================================================
// Analysis
// =========================================================
renderAnalysis(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variableCount =
analysis.variables.length;
const conditionCount =
analysis.conditions.length;
const configuredCount =
Object.keys(
this.state.config &&
this.state.config.fields
? this.state.config.fields
: {}
).length;
const summary =
$('<div>')
.addClass(
'preload-editor-analysis-summary'
);
const createMetric =
(
value,
label
) =>
$('<div>')
.addClass(
'preload-editor-analysis-metric'
)
.append(
$('<strong>')
.addClass(
'preload-editor-analysis-value'
)
.text(value),
$('<span>')
.addClass(
'preload-editor-analysis-label'
)
.text(label)
);
summary.append(
createMetric(
variableCount,
variableCount === 1
? 'variable'
: 'variables'
),
createMetric(
conditionCount,
conditionCount === 1
? 'condition marker'
: 'condition markers'
),
createMetric(
configuredCount,
configuredCount === 1
? 'configured field'
: 'configured fields'
)
);
container.append(summary);
if (
analysis.conditionVariables.length
) {
container.append(
$('<div>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'Condition variables: ' +
analysis.conditionVariables.join(', ')
)
);
}
const validation =
this.validateConfig(
this.state.config,
analysis
);
if (validation.errors.length) {
const errors =
$('<ul>')
.addClass(
'preload-editor-validation-errors'
);
validation.errors.forEach(
error => {
$('<li>')
.text(error)
.appendTo(errors);
}
);
container.append(errors);
}
if (validation.warnings.length) {
const warnings =
$('<ul>')
.addClass(
'preload-editor-validation-warnings'
);
validation.warnings.forEach(
warning => {
$('<li>')
.text(warning)
.appendTo(warnings);
}
);
container.append(warnings);
}
if (
!validation.errors.length &&
!validation.warnings.length
) {
container.append(
$('<div>')
.addClass(
'preload-editor-validation-success'
)
.text(
'Preload and configuration are in sync.'
)
);
}
},
renderAnalysisBySelector() {
const container =
this.root &&
this.root.find(
'.preload-editor-analysis'
);
if (
container &&
container.length
) {
this.renderAnalysis(container);
}
},
// =========================================================
// Variables
// =========================================================
renderVariables(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const variables =
analysis.variables.slice();
const ordered =
['PAGE'];
variables.forEach(
name => {
if (
name !== 'PAGE' &&
!ordered.includes(name)
) {
ordered.push(name);
}
}
);
const configuredFields =
this.state.config &&
this.state.config.fields
? Object.keys(
this.state.config.fields
)
: [];
configuredFields.forEach(
name => {
const upper =
name.toUpperCase();
if (
upper !== 'PAGE' &&
!ordered.includes(upper)
) {
ordered.push(upper);
}
}
);
const deleted =
Array.isArray(
this.state.deletedVariables
)
? this.state.deletedVariables
: [];
deleted.forEach(
name => {
const upper =
String(name).toUpperCase();
if (
upper !== 'PAGE' &&
!ordered.includes(upper)
) {
ordered.push(upper);
}
}
);
const list =
$('<div>')
.addClass(
'preload-editor-variable-list'
);
ordered.forEach(
name => {
const isSelected =
name ===
this.state.selectedVariable;
const isUsed =
analysis.variables.includes(
name
);
const isConfigured =
!!(
this.state.config &&
this.state.config.fields &&
this.state.config.fields[name]
);
const item =
$('<button>')
.attr({
type: 'button',
'data-variable':
name
})
.addClass(
'preload-editor-variable-item'
)
.toggleClass(
'is-selected',
isSelected
)
.on(
'click.preloadEditor',
() =>
this.selectVariable(
name
)
);
$('<span>')
.addClass(
'preload-editor-variable-name'
)
.text(name)
.appendTo(item);
if (name === 'PAGE') {
item.append(
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('AUTOMATIC')
);
} else if (!isConfigured) {
item.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text(
isUsed
? 'NOT CONFIGURED'
: 'DELETED'
)
);
} else if (!isUsed) {
item.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text('UNUSED')
);
}
list.append(item);
}
);
container.append(list);
if (!ordered.length) {
container.append(
$('<div>')
.addClass(
'preload-editor-empty'
)
.text(
'No variables found in the preload.'
)
);
}
},
renderVariablesBySelector() {
const container =
this.root &&
this.root.find(
'.preload-editor-variables'
);
if (
container &&
container.length
) {
this.renderVariables(container);
}
},
selectVariable(name) {
name =
String(name || '')
.trim()
.toUpperCase();
if (!name) {
return;
}
const available =
['PAGE'].concat(
(
this.analysis &&
this.analysis.variables
? this.analysis.variables
: []
).filter(
variable =>
variable !== 'PAGE'
)
);
const configuredUnused =
this.state.config &&
this.state.config.fields
? Object.keys(
this.state.config.fields
)
.map(
variable =>
variable.toUpperCase()
)
: [];
configuredUnused.forEach(
variable => {
if (
!available.includes(variable) &&
variable !== 'PAGE'
) {
available.push(variable);
}
}
);
if (!available.includes(name)) {
return;
}
this.state.selectedVariable =
name;
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
},
renderVariableConfigurationBySelector() {
const container =
this.root &&
this.root.find(
'.preload-editor-variable-configuration'
);
if (
container &&
container.length
) {
this.renderVariableConfiguration(
container
);
}
},
renderVariableConfiguration(container) {
container.empty();
const analysis =
this.analysis ||
this.analyzePreload(
this.extractIncludeonly(
this.getEditorContent()
)
);
const name =
this.state.selectedVariable ||
'PAGE';
const fields =
this.state.config &&
this.state.config.fields
? this.state.config.fields
: {};
const field =
fields[name];
if (name === 'PAGE') {
container.append(
this.createAutomaticPageCard(
field ||
this.getAutomaticPageField()
)
);
return;
}
if (!field) {
const missing =
$('<div>')
.addClass(
'preload-editor-variable preload-editor-variable-missing'
);
$('<div>')
.addClass(
'preload-editor-variable-header'
)
.append(
$('<h3>')
.text(name),
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text(
'NOT CONFIGURED'
)
)
.appendTo(missing);
$('<p>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'This variable is currently not present in config.json.'
)
.appendTo(missing);
container.append(missing);
return;
}
container.append(
this.createVariableCard(
name,
field,
analysis
)
);
},
createAutomaticPageCard(field) {
const card =
$('<div>')
.addClass(
'preload-editor-variable preload-editor-variable-automatic'
)
.attr(
'data-variable',
'PAGE'
);
const header =
$('<div>')
.addClass(
'preload-editor-variable-header'
);
header.append(
$('<h3>').text('PAGE'),
$('<span>')
.addClass(
'preload-editor-badge'
)
.text('AUTOMATIC')
);
card.append(header);
$('<p>')
.addClass(
'preload-editor-analysis-note'
)
.text(
'PAGE is a precompiled automatic variable. It is always available and is not edited like normal variables.'
)
.appendTo(card);
const label =
this.textInput(
field.label ||
'Page Name'
).prop(
'disabled',
true
);
const description =
this.textarea(
field.description ||
'Name of the page.'
)
.addClass(
'preload-editor-small-textarea'
)
.prop(
'disabled',
true
);
const type =
this.textInput(
field.type || 'page'
).prop(
'disabled',
true
);
const required =
this.checkbox(
field.required !== false
).prop(
'disabled',
true
);
const namespace =
this.textInput(
field.namespace || 'Main'
).prop(
'disabled',
true
);
const allowNew =
this.checkbox(
field['allow-new'] === true
).prop(
'disabled',
true
);
card.append(
this.fieldRow(
'Label',
label
),
this.fieldRow(
'Description',
description
),
this.fieldRow(
'Type',
type
),
this.fieldRow(
'Required',
required
),
this.fieldRow(
'Namespace',
namespace
),
this.fieldRow(
'Allow new page',
allowNew
)
);
return card;
},
createVariableCard(
name,
field,
analysis
) {
const card =
$('<div>')
.addClass(
'preload-editor-variable'
)
.attr(
'data-variable',
name
);
const header =
$('<div>')
.addClass(
'preload-editor-variable-header'
);
header.append(
$('<h3>').text(name)
);
if (
!analysis.variables.includes(name)
) {
header.append(
$('<span>')
.addClass(
'preload-editor-badge is-warning'
)
.text('UNUSED')
);
}
card.append(header);
const label =
this.textInput(
field.label || ''
);
label.on(
'input.preloadEditor',
() => {
field.label =
label.val();
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Label',
label
)
);
const description =
this.textarea(
field.description || ''
).addClass(
'preload-editor-small-textarea'
);
description.on(
'input.preloadEditor',
() => {
field.description =
description.val();
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Description',
description
)
);
const types = [
['text', 'Text'],
['textarea', 'Textarea'],
['number', 'Number'],
['boolean', 'Boolean'],
['select', 'Select'],
['multiselect', 'Multi-select'],
['page', 'Page'],
['user', 'User'],
['date', 'Date']
];
const typeSelect =
this.select(
types.map(
([value, label]) => ({
value,
label
})
),
field.type || 'text'
);
typeSelect.on(
'change.preloadEditor',
() => {
field.type =
typeSelect.val();
this.markDirty();
this.renderVariableConfigurationBySelector();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Type',
typeSelect
)
);
const required =
this.checkbox(
field.required === true
);
required.on(
'change.preloadEditor',
() => {
field.required =
required.prop('checked');
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
const placeholder =
this.textInput(
field.placeholder || ''
);
placeholder.on(
'input.preloadEditor',
() => {
const value =
placeholder.val();
if (value) {
field.placeholder =
value;
} else {
delete field.placeholder;
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Placeholder',
placeholder
)
);
const defaultInput =
this.textInput(
field.default === undefined
? ''
: field.default
);
defaultInput.on(
'input.preloadEditor',
() => {
const value =
defaultInput.val();
if (value) {
field.default =
value;
} else {
delete field.default;
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Default',
defaultInput,
'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
)
);
if (
field.type === 'textarea'
) {
const maxRows =
this.textInput(
field.maxRows || ''
).attr(
'type',
'number'
);
maxRows.on(
'input.preloadEditor',
() => {
const value =
parseInt(
maxRows.val(),
10
);
if (
Number.isFinite(value) &&
value > 0
) {
field.maxRows =
value;
} else {
delete field.maxRows;
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Maximum rows',
maxRows
)
);
}
if (
field.type === 'select' ||
field.type === 'multiselect'
) {
this.renderSelectConfiguration(
card,
field
);
}
const deleteButton =
this.createButton(
'Delete variable',
'preload-editor-button-danger preload-editor-delete-variable',
async () => {
const confirmed =
await this.createConfirmDialog(
'Delete variable',
'Delete "' +
name +
'" from the configuration?\n\nThe variable will remain in the preload, but its config.json field will be removed.',
'Delete variable',
'Cancel'
);
if (!confirmed) {
return;
}
this.deleteVariable(name);
}
);
card.append(
$('<div>')
.addClass(
'preload-editor-variable-delete'
)
.append(deleteButton)
);
return card;
},
async deleteVariable(name) {
name =
String(name || '')
.trim()
.toUpperCase();
if (
!name ||
name === 'PAGE'
) {
return;
}
if (
!this.state.config ||
!this.state.config.fields
) {
return;
}
delete this.state.config.fields[name];
if (
!Array.isArray(
this.state.deletedVariables
)
) {
this.state.deletedVariables = [];
}
if (
!this.state.deletedVariables.includes(
name
)
) {
this.state.deletedVariables.push(
name
);
}
this.state.selectedVariable =
name;
this.markDirty();
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
this.renderAnalysisBySelector();
this.renderConfigReviewAvailability();
this.setStatus(
'Variable "' +
name +
'" removed from the configuration.',
'warning'
);
},
// =========================================================
// Select configuration
// =========================================================
renderSelectConfiguration(
card,
field
) {
const source =
this.textInput(
field.source || '',
'category:Example'
);
source.on(
'input.preloadEditor',
() => {
const value =
source.val();
if (value) {
field.source = value;
} else {
delete field.source;
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Source',
source,
'Currently supported source format: category:Category Name'
)
);
const sourcePrefix =
this.textInput(
field['source-prefix'] || ''
);
sourcePrefix.on(
'input.preloadEditor',
() => {
const value =
sourcePrefix.val();
if (value) {
field['source-prefix'] =
value;
} else {
delete field['source-prefix'];
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Source prefix',
sourcePrefix,
'Only source values beginning with this text are included.'
)
);
const excludeCategory =
this.textInput(
field.exclude &&
field.exclude.category ||
''
);
excludeCategory.on(
'input.preloadEditor',
() => {
const value =
excludeCategory.val();
if (value) {
field.exclude =
Object.assign(
{},
field.exclude || {},
{
category: value
}
);
} else if (
field.exclude
) {
delete field.exclude.category;
if (
!Object.keys(
field.exclude
).length
) {
delete field.exclude;
}
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Exclude category',
excludeCategory
)
);
card.append(
this.createTransformControls(
field,
'value',
'Value transformation'
),
this.createTransformControls(
field,
'label',
'Label transformation'
)
);
const optionsValue =
Array.isArray(field.options)
? field.options
.map(
option => {
if (
typeof option ===
'string'
) {
return option;
}
return (
String(
option.value || ''
) +
' | ' +
String(
option.label ||
option.value ||
''
)
);
}
)
.join('\n')
: '';
const options =
this.textarea(
optionsValue
).addClass(
'preload-editor-small-textarea'
);
options.on(
'input.preloadEditor',
() => {
const lines =
options
.val()
.split('\n')
.map(
line =>
line.trim()
)
.filter(Boolean);
if (!lines.length) {
delete field.options;
} else {
field.options =
lines.map(
line => {
const parts =
line.split(
/\s*\|\s*/
);
if (
parts.length ===
1
) {
return parts[0];
}
return {
value:
parts[0],
label:
parts
.slice(1)
.join(
' | '
)
};
}
);
}
this.markDirty();
this.renderConfigReviewAvailability();
}
);
card.append(
this.fieldRow(
'Manual options',
options,
'One per line. Optional label format: value | label. Leave empty when using a source.'
)
);
},
createTransformControls(
field,
property,
title
) {
const wrapper =
$('<div>')
.addClass(
'preload-editor-transform'
);
$('<h4>')
.text(title)
.appendTo(wrapper);
const existing =
field[property] &&
typeof field[property] === 'object'
? field[property]
: {};
const transform =
this.select(
[
{
value: '',
label:
'No transformation'
},
{
value:
'remove-prefix',
label:
'Remove prefix'
},
{
value:
'remove-suffix',
label:
'Remove suffix'
},
{
value: 'replace',
label:
'Replace'
}
],
existing.transform || ''
);
const prefix =
this.textInput(
existing.prefix || ''
);
const suffix =
this.textInput(
existing.suffix || ''
);
const from =
this.textInput(
existing.from || ''
);
const to =
this.textInput(
existing.to || ''
);
const controls =
$('<div>')
.addClass(
'preload-editor-transform-controls'
);
const prefixRow =
this.fieldRow(
'Prefix',
prefix
);
const suffixRow =
this.fieldRow(
'Suffix',
suffix
);
const fromRow =
this.fieldRow(
'From',
from
);
const toRow =
this.fieldRow(
'To',
to
);
const renderInputs =
() => {
const type =
transform.val();
prefixRow.toggle(
type === 'remove-prefix'
);
suffixRow.toggle(
type === 'remove-suffix'
);
fromRow.toggle(
type === 'replace'
);
toRow.toggle(
type === 'replace'
);
};
const update =
() => {
const type =
transform.val();
let object = null;
if (
type === 'remove-prefix' &&
prefix.val()
) {
object = {
transform: type,
prefix:
prefix.val()
};
} else if (
type === 'remove-suffix' &&
suffix.val()
) {
object = {
transform: type,
suffix:
suffix.val()
};
} else if (
type === 'replace' &&
from.val()
) {
object = {
transform: type,
from:
from.val(),
to:
to.val()
};
}
if (object) {
field[property] = object;
} else {
delete field[property];
}
this.markDirty();
this.renderConfigReviewAvailability();
};
transform.on(
'change.preloadEditor',
() => {
update();
renderInputs();
}
);
[
prefix,
suffix,
from,
to
].forEach(
input => {
input.on(
'input.preloadEditor',
update
);
}
);
controls.append(
this.fieldRow(
'Transformation',
transform
),
prefixRow,
suffixRow,
fromRow,
toRow
);
renderInputs();
wrapper.append(controls);
return wrapper;
},
// =========================================================
// Config output
// =========================================================
getCleanConfig() {
const config =
JSON.parse(
JSON.stringify(
this.state.config
)
);
const fields =
config.fields || {};
const orderedFields = {};
orderedFields.PAGE =
this.getAutomaticPageField();
Object.keys(fields)
.filter(
name =>
name.toUpperCase() !== 'PAGE'
)
.forEach(
name => {
orderedFields[name] =
fields[name];
}
);
config.fields =
orderedFields;
return config;
},
getConfigJSON() {
return JSON.stringify(
this.getCleanConfig(),
null,
4
);
},
renderConfigPreview(container) {
if (container) {
container.text(
this.getConfigJSON()
);
}
},
renderConfigPreviewBySelector() {
this.renderAnalysisBySelector();
},
renderConfigReviewAvailability() {
this.renderAnalysisBySelector();
},
// =========================================================
// Dirty state
// =========================================================
updateDirtyState() {
this.state.dirty =
!!(
this.state.dirtyPreload ||
this.state.dirtyConfig
);
},
markPreloadDirty() {
this.state.dirtyPreload = true;
this.updateDirtyState();
this.saveDraft();
},
markConfigDirty() {
this.state.dirtyConfig = true;
this.updateDirtyState();
this.saveDraft();
},
markDirty() {
this.state.dirtyPreload = true;
this.state.dirtyConfig = true;
this.updateDirtyState();
this.saveDraft();
},
// =========================================================
// Save
// =========================================================
async savePreloadOnly() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(
validation
);
this.saveDraft();
return;
}
const confirmed =
await this.confirmSave(
'preload',
title
);
if (!confirmed) {
return;
}
this.setStatus(
'Saving preload...',
'loading'
);
try {
const exists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
exists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!exists
);
this.state.mode = 'edit';
this.state.preloadTitle = title;
this.state.dirtyPreload = false;
this.updateDirtyState();
if (!this.state.dirty) {
this.clearDraft(title);
} else {
this.saveDraft();
}
this.setStatus(
this.state.dirtyConfig
? 'Preload saved. Config still has unsaved changes.'
: 'Preload saved successfully.',
'success'
);
mw.notify(
this.state.dirtyConfig
? 'Preload saved. Config still has unsaved changes.'
: 'Preload saved.',
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.saveDraft();
this.setStatus(
error.message ||
'Could not save preload.',
'error'
);
mw.notify(
error.message ||
'Could not save preload.',
{
type: 'error'
}
);
}
},
async saveConfigOnly() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
this.state.preloadContent =
this.getEditorContent();
this.reanalyze();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(
validation
);
this.saveDraft();
return;
}
const confirmed =
await this.confirmSave(
'config',
title
);
if (!confirmed) {
return;
}
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
this.setStatus(
'Saving config...',
'loading'
);
try {
/*
* This now automatically creates or converts
* config.json to the JSON content model.
*/
await this.saveConfigPage(
configTitle,
this.getConfigJSON(),
(
await this.pageExists(
configTitle
)
)
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor'
);
this.state.mode = 'edit';
this.state.preloadTitle = title;
this.state.dirtyConfig = false;
this.updateDirtyState();
if (!this.state.dirty) {
this.clearDraft(title);
} else {
this.saveDraft();
}
this.setStatus(
this.state.dirtyPreload
? 'Config saved as JSON. Preload still has unsaved changes.'
: 'Config saved as JSON successfully.',
'success'
);
mw.notify(
this.state.dirtyPreload
? 'Config saved as JSON. Preload still has unsaved changes.'
: 'Config saved as JSON.',
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor config save:',
error
);
this.saveDraft();
this.setStatus(
error.message ||
'Could not save config.',
'error'
);
mw.notify(
error.message ||
'Could not save config.',
{
type: 'error'
}
);
}
},
async saveAll() {
let title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
this.state.preloadContent =
this.getEditorContent();
this.reanalyze();
const validation =
this.validateBeforeSave();
if (validation.errors.length) {
this.showValidationErrors(
validation
);
this.saveDraft();
return;
}
const confirmed =
await this.confirmSave(
'both',
title
);
if (!confirmed) {
return;
}
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
this.setStatus(
'Saving preload and config...',
'loading'
);
try {
const preloadExists =
await this.pageExists(title);
await this.savePage(
title,
this.state.preloadContent,
preloadExists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!preloadExists
);
/*
* Config is saved through the JSON-aware
* helper. This means:
*
* - new config -> created as JSON
* - old wikitext config -> converted to JSON
* - existing JSON config -> simply updated
*/
const configExists =
await this.pageExists(
configTitle
);
await this.saveConfigPage(
configTitle,
this.getConfigJSON(),
configExists
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor'
);
this.state.mode = 'edit';
this.state.dirtyPreload = false;
this.state.dirtyConfig = false;
this.state.dirty = false;
this.state.preloadTitle = title;
this.clearDraft(title);
this.setStatus(
'Preload and JSON config saved successfully.',
'success'
);
mw.notify(
'Preload and config saved. config.json is using the JSON content model.',
{
type: 'success'
}
);
} catch (error) {
console.error(
'Preload Editor save:',
error
);
this.saveDraft();
this.setStatus(
error.message ||
'Could not save preload/config.',
'error'
);
mw.notify(
error.message ||
'Could not save preload/config.',
{
type: 'error'
}
);
}
},
// =========================================================
// Validation
// =========================================================
validateBeforeSave() {
const errors = [];
const warnings = [];
const title =
String(
this.state.preloadTitle || ''
).trim();
if (!title) {
errors.push(
'A preload title is required.'
);
}
if (
title &&
!/\/Preload$/i.test(title)
) {
warnings.push(
'The title does not end with "/Preload".'
);
}
const content =
this.getEditorContent();
this.state.preloadContent =
content;
if (!content.trim()) {
errors.push(
'The preload cannot be empty.'
);
}
if (
!/<includeonly>/i.test(content)
) {
warnings.push(
'The preload does not contain an <includeonly> section.'
);
}
if (
!/<noinclude>/i.test(content)
) {
warnings.push(
'The preload does not contain a <noinclude> section.'
);
}
const analysis =
this.analyzePreload(
this.extractIncludeonly(
content
)
);
this.state.config =
this.prepareConfigForVariables(
this.state.config ||
this.emptyConfig(),
analysis
);
const configValidation =
this.validateConfig(
this.state.config,
analysis
);
errors.push(
...configValidation.errors
);
warnings.push(
...configValidation.warnings
);
let depth = 0;
analysis.conditions.forEach(
condition => {
if (
condition.type === 'if'
) {
depth++;
} else if (
condition.type === 'endif'
) {
depth--;
if (depth < 0) {
errors.push(
'Found @endif without a matching @if.'
);
depth = 0;
}
} else if (
condition.type === 'elseif' ||
condition.type === 'else'
) {
if (depth === 0) {
errors.push(
'@' +
condition.type +
' exists outside an @if block.'
);
}
}
}
);
if (depth > 0) {
errors.push(
'One or more @if blocks are missing @endif.'
);
}
return {
errors,
warnings,
valid:
errors.length === 0
};
},
showValidationErrors(validation) {
this.renderAnalysisBySelector();
this.saveDraft();
this.setStatus(
validation.errors.join(' '),
'error'
);
mw.notify(
validation.errors.join('\n'),
{
type: 'error'
}
);
}
};
mw.PreloadEditor =
PreloadEditor;
$(function () {
if (
mw.config.get('wgPageName') !==
'Petit_Planet:Preload_Editor'
) {
return;
}
PreloadEditor.init();
});
}(mediaWiki, jQuery));