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: '0. | version: '0.6.0', | ||
state: { | state: { | ||
| Line 24: | Line 24: | ||
status: null, | status: null, | ||
editorTools: null, | editorTools: null, | ||
/* | |||
* Browser-local draft storage. | |||
* | |||
* This is deliberately separate from the wiki. | |||
* Nothing is saved to MediaWiki until the user | |||
* explicitly clicks one of the Save buttons. | |||
*/ | |||
draftStorageKey: | |||
'PetitPlanet.PreloadEditor.drafts.v1', | |||
defaults: { | defaults: { | ||
| Line 30: | Line 40: | ||
}, | }, | ||
// | // Browser-local draft storage | ||
getDraftStorage() { | |||
const | 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' || | |||
typeof | 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(); | |||
title | |||
if ( | /* | ||
* Untitled drafts are still useful for a new preload | |||
} | * before the user has entered/saved a title. | ||
*/ | |||
if (!title) { | |||
return '__untitled__'; | |||
} | |||
return | return title.toLowerCase(); | ||
}, | }, | ||
// | getDraftSnapshot() { | ||
let content = | |||
this.getEditorContent(); | |||
/* | |||
* If CodeMirror is not mounted yet, use the state | |||
* value instead. | |||
*/ | |||
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: 1, | |||
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' | |||
), | |||
mode: | |||
this.state.mode || | |||
'edit', | |||
dirty: true, | |||
savedAt: | |||
Date.now() | |||
}; | |||
}, | }, | ||
saveDraft() { | |||
/* | |||
* Do not save while the editor is merely showing | |||
this. | * a loaded/saved page with no local changes. | ||
*/ | |||
if (!this.state.dirty) { | |||
return; | |||
} | |||
const snapshot = | |||
this.getDraftSnapshot(); | |||
const id = | |||
this.getDraftId( | |||
snapshot.preloadTitle | |||
); | |||
const storage = | |||
this.getDraftStorage(); | |||
storage.drafts[id] = | |||
snapshot; | |||
/* | |||
* Remember which draft was being worked on most | |||
* recently. This is what lets the editor restore | |||
* the right thing after a crash/reload. | |||
*/ | |||
storage.active = id; | |||
this.setDraftStorage( | |||
storage | |||
); | ); | ||
}, | }, | ||
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 | |||
); | |||
}, | }, | ||
getMostRecentDraft() { | |||
const storage = | |||
this.getDraftStorage(); | |||
if ( | if ( | ||
! | !storage.drafts || | ||
!Object.keys( | |||
storage.drafts | |||
).length | |||
) { | ) { | ||
return null; | |||
} | } | ||
/* | |||
* Prefer the explicitly active draft. | |||
*/ | |||
if ( | if ( | ||
storage.active && | |||
storage.drafts[ | |||
storage.active | |||
] | |||
) { | ) { | ||
return { | |||
id: storage.active, | |||
draft: | |||
storage.drafts[ | |||
storage.active | |||
] | |||
}; | |||
} | } | ||
/* | /* | ||
* | * Fallback: find the most recently saved draft. | ||
*/ | */ | ||
let latestId = null; | |||
let latestDraft = null; | |||
return | 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 | |||
}; | }; | ||
}, | }, | ||
restoreDraft() { | |||
const result = | |||
. | this.getMostRecentDraft(); | ||
if ( | |||
!result || | |||
!result.draft | |||
) { | |||
return false; | |||
} | |||
const | const draft = | ||
result.draft; | |||
const | 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 = | |||
window.confirm( | |||
message | |||
); | |||
if (!restore) { | |||
/* | |||
* The user explicitly rejected recovery, | |||
* so remove that draft instead of asking | |||
* about the same stale work on every reload. | |||
*/ | |||
this.clearDraft( | |||
title | |||
); | |||
return false; | |||
} | |||
let config = | |||
this.emptyConfig(); | |||
if ( | if ( | ||
draft.config && | |||
typeof config | typeof draft.config === | ||
Array.isArray(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 | |||
); | |||
} | |||
} | } | ||
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' | |||
), | |||
mode: | |||
draft.mode || | |||
'edit', | |||
dirty: true | |||
}; | |||
/* | |||
* Reanalyze from the actual restored preload. | |||
* This also recreates any variable configuration | |||
* that may have been introduced before the crash. | |||
*/ | |||
this.reanalyze(); | |||
this.renderEditor(); | |||
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: '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 { | return { | ||
title: page.title || title, | |||
content: | |||
typeof content === 'string' | |||
? content | |||
: '' | |||
}; | }; | ||
}, | }, | ||
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 | |||
) || []; | |||
}, | |||
async savePage( | |||
title, | |||
content, | |||
summary, | |||
createOnly | |||
) { | |||
const params = { | |||
action: 'edit', | |||
title, | |||
text: content, | |||
summary: | |||
summary || | |||
'Updated with Preload Editor', | |||
formatversion: 2 | |||
}; | |||
if (createOnly) { | |||
params.createonly = true; | |||
} | |||
return new mw.Api().postWithToken( | |||
'csrf', | |||
params | |||
); | |||
}, | }, | ||
// | // Preload parsing | ||
extractIncludeonly(source) { | |||
const match = | |||
String(source || '').match( | |||
) { | /<includeonly>([\s\S]*?)<\/includeonly>/i | ||
const | ); | ||
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; | |||
return | |||
}, | }, | ||
findConditions(content) { | |||
const conditions = []; | |||
const pattern = | |||
/<!--\s*@(if|elseif|else|endif)\b([\s\S]*?)-->/gi; | |||
const | |||
let match; | |||
while ( | |||
(match = pattern.exec(content)) !== | |||
null | |||
) { | |||
); | conditions.push({ | ||
type: | |||
match[1].toLowerCase(), | |||
expression: | |||
match[2].trim(), | |||
index: match.index | |||
}); | |||
} | } | ||
return | 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 | 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 || | |||
if (! | typeof config !== 'object' || | ||
Array.isArray(config) | |||
) { | |||
config = | |||
this.emptyConfig(); | |||
} | } | ||
if ( | |||
!config.settings || | |||
typeof config.settings !== | |||
} | 'object' || | ||
Array.isArray( | |||
config.settings | |||
) | |||
) { | |||
config.settings = {}; | |||
} | |||
if ( | if ( | ||
!config.fields || | |||
typeof config.fields !== | |||
'object' || | |||
Array.isArray( | |||
config.fields | |||
) | |||
) { | ) { | ||
config.fields = {}; | |||
} | } | ||
/* | |||
* PAGE is always a special, | |||
* automatic field. | |||
* | |||
* Its configuration is precompiled | |||
* and cannot be edited. | |||
*/ | |||
config.fields.PAGE = | |||
this.getAutomaticPageField(); | |||
return config; | |||
}, | }, | ||
createDefaultField( | |||
return this. | name, | ||
analysis | |||
) { | |||
return { | |||
label: | |||
this.humanizeName( | |||
name | |||
), | |||
description: '', | |||
type: | |||
analysis.conditionVariables.includes( | |||
name | |||
) | |||
? 'boolean' | |||
: 'text' | |||
}; | |||
}, | }, | ||
humanizeName(name) { | |||
return String(name || '') | |||
.replace( | |||
/[_-]+/g, | |||
' ' | |||
this. | ) | ||
.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 | const fields = {}; | ||
/* | |||
* PAGE is always automatic. | |||
*/ | |||
fields.PAGE = | |||
this.getAutomaticPageField(); | |||
/* | |||
* Add every variable currently | |||
* detected in the preload. | |||
* | |||
this. | * Existing configuration is | ||
* preserved. | |||
* | |||
* New variables receive a | |||
* default configuration. | |||
*/ | |||
analysis.variables.forEach( | |||
name => { | |||
if (name === 'PAGE') { | |||
return; | |||
} | |||
fields[name] = | |||
existing[name] || | |||
this.createDefaultField( | |||
name, | |||
analysis | |||
); | |||
} | } | ||
); | |||
/* | |||
* Preserve existing fields even | |||
* if they are currently unused. | |||
* | |||
* This allows users to keep | |||
* configuration around while | |||
* editing the preload. | |||
*/ | |||
Object.keys(existing).forEach( | |||
name => { | |||
const upper = | |||
name.toUpperCase(); | |||
if ( | |||
upper !== 'PAGE' && | |||
!fields[upper] | |||
) { | |||
fields[upper] = | |||
existing[name]; | |||
} | |||
} | |||
); | |||
config.fields = fields; | |||
return config; | |||
}, | }, | ||
validateConfig( | |||
config, | |||
analysis | |||
) { | ) { | ||
const errors = []; | |||
const warnings = []; | |||
if ( | |||
!config || | |||
typeof config !== 'object' || | |||
Array.isArray(config) | |||
) { | |||
return { | |||
valid: false, | |||
errors: [ | |||
'Config must be an object.' | |||
], | |||
warnings: [] | |||
}; | |||
} | |||
if ( | |||
!config.fields || | |||
typeof config.fields !== | |||
'object' || | |||
Array.isArray( | |||
config.fields | |||
) | |||
) { | |||
return { | |||
valid: false, | |||
errors: [ | |||
'"fields" must be an object.' | |||
], | |||
warnings | |||
}; | |||
} | |||
const | 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 ( | 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 | |||
if (! | transformationObject( | ||
return; | 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( | |||
const | options, | ||
value | |||
) { | |||
const select = | |||
$('<select>') | |||
.addClass( | |||
'preload-editor-select' | |||
); | |||
options.forEach( | |||
option => { | |||
$('<option>') | |||
options.forEach( | |||
option => { | |||
$('<option>') | |||
.attr( | .attr( | ||
'value', | 'value', | ||
| Line 1,168: | Line 1,252: | ||
option.label | option.label | ||
) | ) | ||
.appendTo( | .prop( | ||
select | 'selected', | ||
); | String( | ||
} | option.value | ||
); | ) === | ||
String( | |||
value === | |||
undefined | |||
? '' | |||
: value | |||
) | |||
) | |||
.appendTo( | |||
select | |||
); | |||
} | |||
); | |||
return select; | return select; | ||
}, | }, | ||
// CodeMirror | |||
if (!this. | |||
destroyEditor() { | |||
if (!this.editor) { | |||
this.editorView = null; | |||
this.editorTools = null; | |||
return; | return; | ||
} | } | ||
const | const { | ||
this. | 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() { | |||
/* | |||
* Always read directly from the | |||
* CodeMirror state when available. | |||
* | |||
* This is important because the | |||
* native textarea may not contain | |||
* the actual current document. | |||
*/ | |||
if ( | |||
this.editorView && | |||
this.editorView.state | |||
) { | |||
return this.editorView.state.doc.toString(); | |||
} | |||
return ( | |||
this. | this.state.preloadContent || | ||
'' | |||
); | ); | ||
}, | |||
setEditorContent(content) { | |||
content = | |||
String(content || ''); | |||
if (!this.editorView) { | |||
this. | this.state.preloadContent = | ||
content; | |||
return; | return; | ||
} | } | ||
const | const current = | ||
this. | this.editorView.state.doc.toString(); | ||
this. | 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 | 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() { | |||
this. | if (this.editorView) { | ||
this.editorView.focus(); | |||
} | |||
}, | |||
insertEditorText( | |||
text, | |||
selectionFrom, | |||
selectionTo, | |||
cursorPosition | |||
) { | |||
if (!this.editorView) { | |||
return; | |||
} | |||
this. | const view = | ||
this.editorView; | |||
const selection = | |||
view.state.selection.main; | |||
const | const from = | ||
selectionFrom === | |||
. | undefined | ||
? selection.from | |||
: selectionFrom; | |||
const to = | |||
selectionTo === | |||
undefined | |||
? selection.to | |||
: selectionTo; | |||
const | const cursor = | ||
cursorPosition === | |||
undefined | |||
? from + text.length | |||
: cursorPosition; | |||
view.dispatch({ | |||
changes: { | |||
from, | |||
to, | |||
insert: text | |||
}, | |||
selection: { | |||
anchor: cursor | |||
} | |||
}); | |||
view.focus(); | |||
}, | |||
// V2.3 editor tools | |||
wrapSelectionWithVariable() { | |||
const selected = | |||
this.getEditorSelection(); | |||
if ( | |||
!selected || | |||
selected.from === | |||
selected.to | |||
); | ) { | ||
this.focusEditor(); | |||
return; | |||
} | |||
const replacement = | |||
'%%' + | |||
selected.text + | |||
'%%'; | |||
this. | this.insertEditorText( | ||
replacement, | |||
selected.from, | |||
selected.to, | |||
selected.from + | |||
replacement.length | |||
); | |||
}, | |||
insertVariable(name) { | |||
name = | |||
String(name || '') | |||
.trim() | |||
.toUpperCase(); | |||
return | if (!name) { | ||
return; | |||
} | |||
const selected = | |||
this.getEditorSelection(); | |||
if (!selected) { | |||
return; | |||
} | |||
const text = | |||
const | '%%' + | ||
name + | |||
'%%'; | |||
this.insertEditorText( | |||
text, | |||
selected.from, | |||
selected.to | |||
); | |||
}, | }, | ||
insertCondition( | |||
type, | |||
variable | |||
) { | ) { | ||
type = | |||
String(type || '') | |||
.toLowerCase(); | |||
variable = | |||
String(variable || '') | |||
.trim() | |||
.toUpperCase(); | |||
const | const selected = | ||
this. | this.getEditorSelection(); | ||
if (!selected) { | |||
return; | |||
} | |||
let | 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 | |||
); | ); | ||
}, | |||
const | 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 && | |||
if ( | 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 with %%', | |||
'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 = | |||
const | $('<span>') | ||
.addClass( | |||
'preload-editor-condition-variable' | |||
$('< | |||
. | |||
'preload-editor' | |||
) | ) | ||
. | .append( | ||
conditionVariableSelect | |||
); | ); | ||
const insertCondition = | |||
const | |||
this.createButton( | this.createButton( | ||
' | 'Insert', | ||
'preload-editor-button | 'preload-editor-tool-button', | ||
() => | () => | ||
this. | this.insertCondition( | ||
conditionTypeSelect.val(), | |||
conditionVariableSelect.val() | |||
) | ) | ||
); | ); | ||
conditionTypeSelect.on( | |||
'change.preloadEditor', | |||
() => | |||
this.updateConditionVariableVisibility() | |||
); | |||
conditionGroup.append( | |||
conditionTypeSelect, | |||
conditionVariableWrap, | |||
insertCondition | |||
); | ); | ||
tools.append( | |||
wrapGroup, | |||
variableGroup, | |||
conditionGroup | |||
); | ); | ||
this. | this.editorTools = { | ||
root: tools, | |||
variableSelect, | |||
conditionTypeSelect, | |||
conditionVariableSelect, | |||
conditionVariableWrap, | |||
insertCondition | |||
}; | |||
this. | this.updateEditorToolVariables(); | ||
this.updateConditionVariableVisibility(); | |||
return tools; | |||
}, | |||
this. | updateEditorTools() { | ||
this.updateEditorToolVariables(); | |||
this.updateConditionVariableVisibility(); | |||
this. | |||
}, | }, | ||
// Preload syntax highlighting | |||
const | 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. | 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 | 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 | |||
this. | |||
) | ) | ||
: decorations, | |||
provide: field => | |||
EditorView.decorations.from( | |||
field | |||
) | ) | ||
}); | |||
view.dispatch({ | |||
effects: | |||
); | StateEffect.appendConfig.of( | ||
syntaxField | |||
) | |||
}); | |||
this.editor.syntaxField = | |||
syntaxField; | |||
}, | }, | ||
async | async mountCodeMirror( | ||
textarea | |||
) { | ) { | ||
return new Promise( | |||
( | |||
resolve, | |||
reject | |||
) => { | |||
let settled = false; | |||
const cleanup = | |||
() => { | |||
mw.hook( | |||
'dev.CCM.ready' | |||
).remove( | |||
onReady | |||
); | |||
mw.hook( | |||
'dev.CCM.load' | |||
).remove( | |||
onLoad | |||
); | |||
}; | |||
const onReady = | |||
async cm => { | |||
if ( | |||
settled || | |||
!cm || | |||
cm.textarea !== | |||
textarea || | |||
!cm.view | |||
) { | |||
return; | |||
} | |||
settled = true; | |||
cleanup(); | |||
const view = | |||
cm.view; | |||
const inputHandler = | |||
() => { | |||
this.state | |||
.preloadContent = | |||
view.state.doc.toString(); | |||
this.state.dirty = | |||
true; | |||
/* | |||
* Persist immediately. | |||
* | |||
* This is deliberately not debounced: | |||
* the purpose of the local draft is | |||
* crash recovery. | |||
*/ | |||
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 | |||
); | |||
/* | |||
* Make sure the restored/new | |||
* state is also persisted once | |||
* CodeMirror is fully mounted. | |||
*/ | |||
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(); | |||
/* | |||
* Restore after the shell exists. | |||
* | |||
* This means a crash/reload can immediately | |||
* bring the previous working document back. | |||
*/ | |||
this.restoreDraft(); | |||
/* | |||
* Extra safety net for normal reloads/navigation. | |||
* | |||
* Browser crashes cannot always fire beforeunload, | |||
* which is why we also persist on every edit. | |||
*/ | |||
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' | |||
); | |||
/* | |||
* Keep the title in state for new/untitled drafts | |||
* as well. This makes crash recovery more useful | |||
* before the preload has ever been saved. | |||
*/ | |||
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.status = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-status' | |||
); | |||
this.root.append( | |||
this.status | |||
); | |||
this.workspace = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-workspace' | |||
' | |||
); | ); | ||
this.root.append( | |||
this. | this.workspace | ||
); | |||
this.renderWelcome(); | |||
}, | |||
this. | 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( | |||
this. | 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. | |||
); | ); | ||
} | } | ||
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( | |||
this. | page.title, | ||
page.content | |||
); | ); | ||
this.setStatus( | |||
'Preload loaded.', | |||
'success' | |||
); | |||
} catch ( | |||
error | |||
) { | |||
console.error( | |||
'Preload Editor:', | |||
error | |||
); | |||
' | |||
'preload | 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 | const section = | ||
this.createSection( | this.createSection( | ||
' | 'Preload not found' | ||
); | ); | ||
$('<p>') | |||
.text( | |||
'No preload exists at "' + | |||
title + | |||
'".' | |||
) | |||
.appendTo( | |||
section | |||
); | ); | ||
if ( | |||
$('< | suggestions.length | ||
. | ) { | ||
' | $('<h3>') | ||
.text( | |||
'Similar preloads' | |||
) | |||
.appendTo( | |||
section | |||
); | ); | ||
const list = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-suggestions' | |||
); | |||
suggestions.forEach( | |||
result => { | |||
list.append( | |||
this.createButton( | |||
); | result.title, | ||
'preload-editor-suggestion', | |||
() => | |||
this.loadExisting( | |||
result.title | |||
) | |||
) | |||
); | |||
} | |||
); | |||
section.append( | |||
list | |||
); | |||
} | |||
section.append( | |||
this.createButton( | this.createButton( | ||
' | 'Create this preload', | ||
'preload-editor-button-primary', | 'preload-editor-button-primary', | ||
() => | () => | ||
this. | this.startNew( | ||
title | |||
) | |||
) | ) | ||
); | ); | ||
this.workspace.append( | |||
section | |||
); | ); | ||
}, | |||
async startNew( | |||
title | |||
) { | |||
title = | |||
String(title || '') | |||
.trim(); | |||
this. | this.state = { | ||
preloadTitle: title, | |||
preloadSource: '', | |||
preloadContent: | |||
this.defaults.preload, | |||
config: | |||
this.emptyConfig(), | |||
variables: ['PAGE'], | |||
conditions: [], | |||
selectedVariable: 'PAGE', | |||
mode: 'new', | |||
dirty: true | |||
}; | |||
this. | this.saveDraft(); | ||
this. | this.renderEditor(); | ||
this. | this.setStatus( | ||
'Creating a new preload.', | |||
'success' | |||
); | ); | ||
}, | |||
this. | async loadSamplePage() { | ||
const result = | |||
this. | 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', | |||
mode: 'new', | |||
dirty: true | |||
}; | |||
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' | |||
); | |||
error | |||
' | |||
} | } | ||
}, | }, | ||
convertSampleToPreload( | |||
source | |||
) { | |||
let content = | |||
String(source || '') | |||
.trim(); | |||
const | const include = | ||
content.match( | |||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | ); | ||
if (include) { | |||
content = | |||
include[1].trim(); | |||
} | |||
content = | |||
content | |||
.replace( | |||
/<noinclude>[\s\S]*?<\/noinclude>/gi, | |||
'' | |||
) | |||
.trim(); | |||
return ( | |||
'<includeonly>\n' + | |||
content + | |||
'\n</includeonly>\n' + | |||
'<noinclude>{{Documentation|type=Preload|content={{Preload Template}}}}</noinclude>\n' | |||
); | |||
}, | |||
createPromptDialog( | |||
title, | |||
message | |||
) { | |||
return new Promise( | |||
resolve => { | |||
const overlay = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt-overlay' | |||
); | |||
const box = | |||
$('<div>') | |||
.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( | .append( | ||
overlay | |||
); | |||
input.trigger( | |||
'focus' | |||
); | |||
input.on( | |||
'keydown.preloadEditor', | |||
event => { | |||
if ( | |||
event.key === | |||
'Enter' | |||
) { | |||
confirm.trigger( | |||
'click' | |||
); | |||
} | |||
if ( | |||
event.key === | |||
'Escape' | |||
) { | |||
cancel.trigger( | |||
), | 'click' | ||
); | |||
} | |||
} | |||
); | |||
} | |||
); | |||
}, | |||
async loadDefinition( | |||
title, | |||
source | |||
) { | |||
const content = | |||
this.extractIncludeonly( | |||
source | |||
); | |||
let config = | |||
this.emptyConfig(); | |||
const configTitle = | |||
title.replace( | |||
/\/+$/, | |||
'' | |||
) + | |||
$ | '/config.json'; | ||
const | const configPage = | ||
this. | await this.getPage( | ||
configTitle | |||
); | ); | ||
if ( | if (configPage) { | ||
try { | |||
config = | |||
JSON.parse( | |||
configPage.content | |||
. | |||
); | ); | ||
} catch ( | |||
error | |||
) { | |||
throw new Error( | |||
'The existing config.json contains invalid JSON: ' + | |||
error.message | |||
); | |||
} | |||
} | |||
const analysis = | |||
this.analyzePreload( | |||
content | |||
); | ); | ||
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' | |||
), | |||
mode: 'edit', | |||
dirty: false | |||
}; | |||
} | |||
/* | |||
* Loading the current server version means | |||
* that this is now the canonical working copy. | |||
* | |||
* Any old local draft for this exact preload | |||
* has either already been restored or was | |||
* explicitly declined. | |||
*/ | |||
this.clearDraft(title); | |||
this.renderEditor(); | |||
}, | }, | ||
/ | // Editor | ||
renderEditor() { | |||
this.destroyEditor(); | |||
this.workspace.empty(); | |||
const analysis = | const layout = | ||
this.analysis || | $('<div>') | ||
this.analyzePreload( | .addClass( | ||
this.extractIncludeonly( | 'preload-editor-layout' | ||
this.getEditorContent() | ); | ||
) | |||
); | 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 | |||
); | |||
/* | |||
* RIGHT PANEL | |||
* | |||
* Analysis | |||
* Variables | |||
* Variable configuration | |||
* Generated configuration | |||
* Actions | |||
*/ | |||
const analysisSection = | |||
this.createSection( | |||
'Analysis' | |||
); | |||
const analysisContainer = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis' | |||
); | |||
analysisSection.append( | |||
analysisContainer | |||
); | |||
const variablesSection = | |||
this.createSection( | |||
'Variables' | |||
); | |||
/* | |||
* Explicit variable detection button. | |||
*/ | |||
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.' | |||
); | |||
const detectControls = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-detect' | |||
) | |||
.append( | |||
detectVariablesButton, | |||
detectHelp | |||
); | |||
variablesSection.append( | |||
detectControls | |||
); | |||
const variableContainer = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variables' | |||
); | |||
variablesSection.append( | |||
variableContainer | |||
); | |||
const configurationSection = | |||
this.createSection( | |||
'Variable configuration' | |||
); | |||
const configurationContainer = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-configuration' | |||
); | |||
configurationSection.append( | |||
configurationContainer | |||
); | |||
const configSection = | |||
this.createSection( | |||
'Generated configuration' | |||
); | |||
const configPreview = | |||
$('<pre>') | |||
.addClass( | |||
'preload-editor-config-preview' | |||
); | |||
configSection.append( | |||
configPreview | |||
); | |||
const actions = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-actions' | |||
); | |||
actions.append( | |||
this.createButton( | |||
'Save Preload + Config', | |||
'preload-editor-button-primary', | |||
() => | |||
this.saveAll() | |||
), | |||
this.createButton( | |||
'Save Preload Only', | |||
'', | |||
() => | |||
this.savePreloadOnly() | |||
) | |||
); | |||
right.append( | |||
analysisSection, | |||
variablesSection, | |||
configurationSection, | |||
configSection, | |||
actions | |||
); | |||
layout.append( | |||
left, | |||
right | |||
); | |||
this.workspace.append( | |||
layout | |||
); | |||
this.reanalyze(); | |||
this.renderVariables( | |||
variableContainer | |||
); | |||
this.renderAnalysis( | |||
analysisContainer | |||
); | |||
this.renderVariableConfiguration( | |||
configurationContainer | |||
); | |||
this.renderConfigPreview( | |||
configPreview | |||
); | |||
this.updateEditorTools(); | |||
this.mountCodeMirror( | |||
textarea.get(0) | |||
).catch( | |||
error => { | |||
console.error( | |||
'Preload Editor CodeMirror:', | |||
error | |||
); | |||
this.setStatus( | |||
error.message || | |||
'Could not initialize CodeMirror.', | |||
'error' | |||
); | |||
} | |||
); | |||
}, | |||
/* | |||
* Explicitly re-read the CodeMirror document | |||
* and refresh every part of the right panel. | |||
*/ | |||
detectVariables() { | |||
const source = | |||
this.getEditorContent(); | |||
this.state.preloadContent = | |||
source; | |||
this.state.dirty = | |||
true; | |||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.renderConfigPreviewBySelector(); | |||
this.updateEditorTools(); | |||
if ( | |||
this.editorView && | |||
this.editor && | |||
this.editor.syntaxField | |||
) { | |||
try { | |||
this.editorView.dispatch({ | |||
effects: | |||
this.editorView.state | |||
.field( | |||
this.editor.syntaxField | |||
) | |||
? [] | |||
: [] | |||
}); | |||
} catch ( | |||
error | |||
) { | |||
console.warn( | |||
'Preload Editor syntax refresh:', | |||
error | |||
); | |||
} | |||
} | |||
/* | |||
* Detection itself changes/synchronizes the | |||
* working state, so persist the resulting | |||
* draft immediately. | |||
*/ | |||
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'; | |||
} | |||
}, | |||
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 | |||
); | |||
} | |||
}, | |||
/* | |||
* Variable list | |||
*/ | |||
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 | |||
); | |||
} | |||
} | |||
); | |||
/* | |||
* Preserve configured-but-unused fields | |||
* in the compact list. | |||
*/ | |||
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 list = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-list' | |||
); | |||
ordered.forEach( | |||
name => { | |||
const isSelected = | |||
name === | |||
this.state | |||
.selectedVariable; | |||
const isUsed = | |||
analysis.variables.includes( | |||
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 | |||
) | |||
); | |||
const nameLabel = | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-variable-name' | |||
) | |||
.text(name); | |||
item.append( | |||
nameLabel | |||
); | |||
if ( | if ( | ||
name | name === | ||
'PAGE' | |||
) { | |||
) | item.append( | ||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge' | |||
) | |||
.text( | |||
'AUTOMATIC' | |||
) | |||
); | |||
} else if ( | |||
!isUsed | |||
) { | ) { | ||
item.append( | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge is-warning' | |||
) | |||
.text( | |||
'UNUSED' | |||
) | |||
); | ); | ||
} | } | ||
list.append( | |||
item | |||
); | |||
} | } | ||
); | ); | ||
container.append( | |||
list | |||
); | |||
ordered. | 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 | |||
); | |||
} | |||
}, | |||
/* | |||
* Select a variable and display only | |||
* its configuration. | |||
* | |||
* No scrolling. | |||
*/ | |||
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' | |||
) | |||
); | |||
. | |||
) | |||
if ( | if ( | ||
! | !available.includes( | ||
name | |||
) | |||
) { | ) { | ||
return; | |||
} | } | ||
this.state.selectedVariable = | |||
name; | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
}, | }, | ||
renderVariableConfigurationBySelector() { | |||
const container = | const container = | ||
this.root && | this.root && | ||
this.root.find( | this.root.find( | ||
'.preload-editor- | '.preload-editor-variable-configuration' | ||
); | ); | ||
| Line 3,317: | Line 3,916: | ||
container.length | container.length | ||
) { | ) { | ||
this. | this.renderVariableConfiguration( | ||
container | container | ||
); | ); | ||
| Line 3,323: | Line 3,922: | ||
}, | }, | ||
renderVariableConfiguration( | |||
container | |||
) { | ) { | ||
container.empty(); | |||
const analysis = | |||
this.analysis || | |||
this.analyzePreload( | |||
const analysis = | |||
this.analysis || | |||
this.analyzePreload( | |||
this.extractIncludeonly( | this.extractIncludeonly( | ||
this.getEditorContent() | this.getEditorContent() | ||
| Line 4,473: | Line 5,006: | ||
markDirty() { | markDirty() { | ||
this.state.dirty = true; | this.state.dirty = | ||
true; | |||
/* | |||
* Any configuration change is also a | |||
* local draft change. | |||
*/ | |||
this.saveDraft(); | |||
}, | }, | ||
| Line 4,518: | Line 5,058: | ||
validation | validation | ||
); | ); | ||
/* | |||
* Keep the local draft because the | |||
* save did not complete. | |||
*/ | |||
this.saveDraft(); | |||
return; | return; | ||
| Line 4,547: | Line 5,093: | ||
this.state.dirty = | this.state.dirty = | ||
false; | false; | ||
/* | |||
* The preload has now been explicitly | |||
* saved, so its local recovery copy is | |||
* no longer needed. | |||
*/ | |||
this.clearDraft( | |||
title | |||
); | |||
this.setStatus( | this.setStatus( | ||
| Line 4,567: | Line 5,122: | ||
error | error | ||
); | ); | ||
/* | |||
* Keep the local draft intact if | |||
* the wiki save failed. | |||
*/ | |||
this.saveDraft(); | |||
this.setStatus( | this.setStatus( | ||
| Line 4,620: | Line 5,181: | ||
* synchronized with anything newly | * synchronized with anything newly | ||
* typed into the preload. | * typed into the preload. | ||
*/ | */ | ||
this.reanalyze(); | this.reanalyze(); | ||
| Line 4,636: | Line 5,193: | ||
validation | validation | ||
); | ); | ||
/* | |||
* Keep the recovery copy. | |||
*/ | |||
this.saveDraft(); | |||
return; | return; | ||
| Line 4,689: | Line 5,251: | ||
this.state.preloadTitle = | this.state.preloadTitle = | ||
title; | title; | ||
/* | |||
* Both the preload and config are now | |||
* safely on the wiki. | |||
*/ | |||
this.clearDraft( | |||
title | |||
); | |||
this.setStatus( | this.setStatus( | ||
| Line 4,709: | Line 5,279: | ||
error | error | ||
); | ); | ||
/* | |||
* IMPORTANT: | |||
* if either wiki save fails, retain the | |||
* browser draft so nothing is lost. | |||
*/ | |||
this.saveDraft(); | |||
this.setStatus( | this.setStatus( | ||
| Line 4,796: | Line 5,373: | ||
* Synchronize configuration one final | * Synchronize configuration one final | ||
* time before saving. | * time before saving. | ||
*/ | */ | ||
this.state.config = | this.state.config = | ||
| Line 4,882: | Line 5,456: | ||
) { | ) { | ||
this.renderAnalysisBySelector(); | this.renderAnalysisBySelector(); | ||
/* | |||
* Keep the local recovery copy because | |||
* validation prevented the wiki save. | |||
*/ | |||
this.saveDraft(); | |||
this.setStatus( | this.setStatus( | ||
Revision as of 10:16, 21 September 2026
(function (mw, $) {
'use strict';
const PreloadEditor = {
version: '0.6.0',
state: {
preloadTitle: '',
preloadSource: '',
preloadContent: '',
config: null,
variables: [],
conditions: [],
selectedVariable: 'PAGE',
mode: 'idle',
dirty: false
},
editor: null,
editorView: null,
analysis: null,
root: null,
workspace: null,
status: null,
editorTools: null,
/*
* Browser-local draft storage.
*
* This is deliberately separate from the wiki.
* Nothing is saved to MediaWiki until the user
* explicitly clicks one of the Save buttons.
*/
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();
/*
* Untitled drafts are still useful for a new preload
* before the user has entered/saved a title.
*/
if (!title) {
return '__untitled__';
}
return title.toLowerCase();
},
getDraftSnapshot() {
let content =
this.getEditorContent();
/*
* If CodeMirror is not mounted yet, use the state
* value instead.
*/
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: 1,
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'
),
mode:
this.state.mode ||
'edit',
dirty: true,
savedAt:
Date.now()
};
},
saveDraft() {
/*
* Do not save while the editor is merely showing
* a loaded/saved page with no local changes.
*/
if (!this.state.dirty) {
return;
}
const snapshot =
this.getDraftSnapshot();
const id =
this.getDraftId(
snapshot.preloadTitle
);
const storage =
this.getDraftStorage();
storage.drafts[id] =
snapshot;
/*
* Remember which draft was being worked on most
* recently. This is what lets the editor restore
* the right thing after a crash/reload.
*/
storage.active = id;
this.setDraftStorage(
storage
);
},
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
);
},
getMostRecentDraft() {
const storage =
this.getDraftStorage();
if (
!storage.drafts ||
!Object.keys(
storage.drafts
).length
) {
return null;
}
/*
* Prefer the explicitly active draft.
*/
if (
storage.active &&
storage.drafts[
storage.active
]
) {
return {
id: storage.active,
draft:
storage.drafts[
storage.active
]
};
}
/*
* Fallback: find the most recently saved draft.
*/
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
};
},
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 =
window.confirm(
message
);
if (!restore) {
/*
* The user explicitly rejected recovery,
* so remove that draft instead of asking
* about the same stale work on every reload.
*/
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
);
}
}
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'
),
mode:
draft.mode ||
'edit',
dirty: true
};
/*
* Reanalyze from the actual restored preload.
* This also recreates any variable configuration
* that may have been introduced before the crash.
*/
this.reanalyze();
this.renderEditor();
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: 'revisions',
titles: title,
rvprop: 'content',
rvslots: 'main',
formatversion: 2
});
const page =
response &&
response.query &&
response.query.pages &&
response.query.pages[0];
if (!page || page.missing) return null;
const content =
page.revisions &&
page.revisions[0] &&
page.revisions[0].slots &&
page.revisions[0].slots.main &&
page.revisions[0].slots.main.content;
return {
title: page.title || title,
content:
typeof content === 'string'
? content
: ''
};
},
async pageExists(title) {
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
) || [];
},
async savePage(
title,
content,
summary,
createOnly
) {
const params = {
action: 'edit',
title,
text: content,
summary:
summary ||
'Updated with Preload Editor',
formatversion: 2
};
if (createOnly) {
params.createonly = true;
}
return new mw.Api().postWithToken(
'csrf',
params
);
},
// 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 = {};
}
/*
* PAGE is always a special,
* automatic field.
*
* Its configuration is precompiled
* and cannot be edited.
*/
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 fields = {};
/*
* PAGE is always automatic.
*/
fields.PAGE =
this.getAutomaticPageField();
/*
* Add every variable currently
* detected in the preload.
*
* Existing configuration is
* preserved.
*
* New variables receive a
* default configuration.
*/
analysis.variables.forEach(
name => {
if (name === 'PAGE') {
return;
}
fields[name] =
existing[name] ||
this.createDefaultField(
name,
analysis
);
}
);
/*
* Preserve existing fields even
* if they are currently unused.
*
* This allows users to keep
* configuration around while
* editing the preload.
*/
Object.keys(existing).forEach(
name => {
const upper =
name.toUpperCase();
if (
upper !== 'PAGE' &&
!fields[upper]
) {
fields[upper] =
existing[name];
}
}
);
config.fields = fields;
return config;
},
validateConfig(
config,
analysis
) {
const errors = [];
const warnings = [];
if (
!config ||
typeof config !== 'object' ||
Array.isArray(config)
) {
return {
valid: false,
errors: [
'Config must be an object.'
],
warnings: []
};
}
if (
!config.fields ||
typeof config.fields !==
'object' ||
Array.isArray(
config.fields
)
) {
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;
},
// 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() {
/*
* Always read directly from the
* CodeMirror state when available.
*
* This is important because the
* native textarea may not contain
* the actual current document.
*/
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();
},
// V2.3 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;
}
const text =
'%%' +
name +
'%%';
this.insertEditorText(
text,
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 with %%',
'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();
},
// Preload 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.state.dirty =
true;
/*
* Persist immediately.
*
* This is deliberately not debounced:
* the purpose of the local draft is
* crash recovery.
*/
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
);
/*
* Make sure the restored/new
* state is also persisted once
* CodeMirror is fully mounted.
*/
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();
/*
* Restore after the shell exists.
*
* This means a crash/reload can immediately
* bring the previous working document back.
*/
this.restoreDraft();
/*
* Extra safety net for normal reloads/navigation.
*
* Browser crashes cannot always fire beforeunload,
* which is why we also persist on every edit.
*/
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'
);
/*
* Keep the title in state for new/untitled drafts
* as well. This makes crash recovery more useful
* before the preload has ever been saved.
*/
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.status =
$('<div>')
.addClass(
'preload-editor-status'
);
this.root.append(
this.status
);
this.workspace =
$('<div>')
.addClass(
'preload-editor-workspace'
);
this.root.append(
this.workspace
);
this.renderWelcome();
},
renderWelcome() {
this.workspace.empty();
const section =
this.createSection(
'Get started'
);
$('<p>')
.text(
'Load an existing preload, create a new one, or start from a sample wiki page.'
)
.appendTo(
section
);
const tips =
$('<ul>')
.addClass(
'preload-editor-tips'
);
[
'Existing preload: loads the preload and its config.json if available.',
'New preload: starts with the standard Preload Template structure.',
'Sample page: loads normal wiki page content so you can turn it into a preload.'
].forEach(
text => {
$('<li>')
.text(text)
.appendTo(
tips
);
}
);
section.append(
tips
);
this.workspace.append(
section
);
},
setStatus(
message,
type
) {
if (!this.status) {
return;
}
this.status.removeClass(
'is-error',
'is-success',
'is-warning',
'is-loading'
);
if (type) {
this.status.addClass(
'is-' + type
);
}
this.status.text(
message || ''
);
},
// Load / create
async loadExisting(
title
) {
title =
String(title || '')
.trim();
if (!title) {
mw.notify(
'Enter a preload page title first.',
{
type: 'warn'
}
);
return;
}
this.setStatus(
'Loading preload...',
'loading'
);
try {
const page =
await this.getPage(
title
);
if (!page) {
const suggestions =
await this.findSimilarPreloads(
title
);
this.renderMissingPage(
title,
suggestions
);
this.setStatus(
'Preload not found.',
'warning'
);
return;
}
await this.loadDefinition(
page.title,
page.content
);
this.setStatus(
'Preload loaded.',
'success'
);
} catch (
error
) {
console.error(
'Preload Editor:',
error
);
this.setStatus(
error.message ||
'Could not load preload.',
'error'
);
}
},
async findSimilarPreloads(
title
) {
let query =
title
.replace(
/\/Preload$/i,
''
)
.trim();
if (!query) {
query = title;
}
try {
const results =
await this.searchPages(
query
);
return results.filter(
result =>
/\/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',
mode: 'new',
dirty: true
};
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',
mode: 'new',
dirty: true
};
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'
);
},
createPromptDialog(
title,
message
) {
return new Promise(
resolve => {
const overlay =
$('<div>')
.addClass(
'preload-editor-prompt-overlay'
);
const box =
$('<div>')
.addClass(
'preload-editor-prompt'
);
$('<h2>')
.text(title)
.appendTo(
box
);
$('<p>')
.text(message)
.appendTo(
box
);
const input =
this.textInput();
const buttons =
$('<div>')
.addClass(
'preload-editor-prompt-buttons'
);
const cancel =
this.createButton(
'Cancel',
'',
() => {
overlay.remove();
resolve(null);
}
);
const confirm =
this.createButton(
'Load',
'preload-editor-button-primary',
() => {
const value =
String(
input.val() ||
''
).trim();
if (!value) {
return;
}
overlay.remove();
resolve(value);
}
);
buttons.append(
cancel,
confirm
);
box.append(
input,
buttons
);
overlay.append(
box
);
$(document.body)
.append(
overlay
);
input.trigger(
'focus'
);
input.on(
'keydown.preloadEditor',
event => {
if (
event.key ===
'Enter'
) {
confirm.trigger(
'click'
);
}
if (
event.key ===
'Escape'
) {
cancel.trigger(
'click'
);
}
}
);
}
);
},
async loadDefinition(
title,
source
) {
const content =
this.extractIncludeonly(
source
);
let config =
this.emptyConfig();
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
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
);
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'
),
mode: 'edit',
dirty: false
};
/*
* Loading the current server version means
* that this is now the canonical working copy.
*
* Any old local draft for this exact preload
* has either already been restored or was
* explicitly declined.
*/
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
);
/*
* RIGHT PANEL
*
* Analysis
* Variables
* Variable configuration
* Generated configuration
* Actions
*/
const analysisSection =
this.createSection(
'Analysis'
);
const analysisContainer =
$('<div>')
.addClass(
'preload-editor-analysis'
);
analysisSection.append(
analysisContainer
);
const variablesSection =
this.createSection(
'Variables'
);
/*
* Explicit variable detection button.
*/
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.'
);
const detectControls =
$('<div>')
.addClass(
'preload-editor-variable-detect'
)
.append(
detectVariablesButton,
detectHelp
);
variablesSection.append(
detectControls
);
const variableContainer =
$('<div>')
.addClass(
'preload-editor-variables'
);
variablesSection.append(
variableContainer
);
const configurationSection =
this.createSection(
'Variable configuration'
);
const configurationContainer =
$('<div>')
.addClass(
'preload-editor-variable-configuration'
);
configurationSection.append(
configurationContainer
);
const configSection =
this.createSection(
'Generated configuration'
);
const configPreview =
$('<pre>')
.addClass(
'preload-editor-config-preview'
);
configSection.append(
configPreview
);
const actions =
$('<div>')
.addClass(
'preload-editor-actions'
);
actions.append(
this.createButton(
'Save Preload + Config',
'preload-editor-button-primary',
() =>
this.saveAll()
),
this.createButton(
'Save Preload Only',
'',
() =>
this.savePreloadOnly()
)
);
right.append(
analysisSection,
variablesSection,
configurationSection,
configSection,
actions
);
layout.append(
left,
right
);
this.workspace.append(
layout
);
this.reanalyze();
this.renderVariables(
variableContainer
);
this.renderAnalysis(
analysisContainer
);
this.renderVariableConfiguration(
configurationContainer
);
this.renderConfigPreview(
configPreview
);
this.updateEditorTools();
this.mountCodeMirror(
textarea.get(0)
).catch(
error => {
console.error(
'Preload Editor CodeMirror:',
error
);
this.setStatus(
error.message ||
'Could not initialize CodeMirror.',
'error'
);
}
);
},
/*
* Explicitly re-read the CodeMirror document
* and refresh every part of the right panel.
*/
detectVariables() {
const source =
this.getEditorContent();
this.state.preloadContent =
source;
this.state.dirty =
true;
this.reanalyze();
this.renderVariablesBySelector();
this.renderVariableConfigurationBySelector();
this.renderAnalysisBySelector();
this.renderConfigPreviewBySelector();
this.updateEditorTools();
if (
this.editorView &&
this.editor &&
this.editor.syntaxField
) {
try {
this.editorView.dispatch({
effects:
this.editorView.state
.field(
this.editor.syntaxField
)
? []
: []
});
} catch (
error
) {
console.warn(
'Preload Editor syntax refresh:',
error
);
}
}
/*
* Detection itself changes/synchronizes the
* working state, so persist the resulting
* draft immediately.
*/
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';
}
},
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
);
}
},
/*
* Variable list
*/
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
);
}
}
);
/*
* Preserve configured-but-unused fields
* in the compact list.
*/
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 list =
$('<div>')
.addClass(
'preload-editor-variable-list'
);
ordered.forEach(
name => {
const isSelected =
name ===
this.state
.selectedVariable;
const isUsed =
analysis.variables.includes(
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
)
);
const nameLabel =
$('<span>')
.addClass(
'preload-editor-variable-name'
)
.text(name);
item.append(
nameLabel
);
if (
name ===
'PAGE'
) {
item.append(
$('<span>')
.addClass(
'preload-editor-badge'
)
.text(
'AUTOMATIC'
)
);
} 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
);
}
},
/*
* Select a variable and display only
* its configuration.
*
* No scrolling.
*/
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'
)
);
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] ||
this.createDefaultField(
name,
analysis
);
if (
name ===
'PAGE'
) {
container.append(
this.createAutomaticPageCard(
field
)
);
return;
}
container.append(
this.createVariableCard(
name,
field,
analysis
)
);
},
/*
* PAGE is automatic/precompiled.
*/
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.renderConfigPreviewBySelector();
}
);
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.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Description',
description
)
);
const types = [
[
'text',
'Text'
],
[
'textarea',
'Textarea'
],
[
'number',
'Number'
],
[
'boolean',
'Boolean'
],
[
'select',
'Select'
],
[
'multiselect',
'Multi-select'
],
[
'page',
'Page'
],
[
'user',
'User'
],
[
'date',
'Date'
]
];
const typeSelect =
this.select(
types.map(
(
[
value,
label
]
) => ({
value,
label
})
),
field.type ||
'text'
);
typeSelect.on(
'change.preloadEditor',
() => {
field.type =
typeSelect.val();
this.markDirty();
this.renderVariableConfigurationBySelector();
this.renderConfigPreviewBySelector();
}
);
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.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Required',
required
)
);
const placeholder =
this.textInput(
field.placeholder ||
''
);
placeholder.on(
'input.preloadEditor',
() => {
const value =
placeholder.val();
if (value) {
field.placeholder =
value;
} else {
delete field.placeholder;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'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.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Default',
defaultInput,
'For text-like fields this is used as a placeholder. For select fields it is the empty/default option.'
)
);
if (
field.type ===
'textarea'
) {
const maxRows =
this.textInput(
field.maxRows ||
''
).attr(
'type',
'number'
);
maxRows.on(
'input.preloadEditor',
() => {
const value =
parseInt(
maxRows.val(),
10
);
if (
Number.isFinite(
value
) &&
value > 0
) {
field.maxRows =
value;
} else {
delete field.maxRows;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Maximum rows',
maxRows
)
);
}
if (
field.type ===
'select' ||
field.type ===
'multiselect'
) {
this.renderSelectConfiguration(
card,
field
);
}
return card;
},
renderSelectConfiguration(
card,
field
) {
const source =
this.textInput(
field.source || '',
'category:Example'
);
source.on(
'input.preloadEditor',
() => {
const value =
source.val();
if (value) {
field.source =
value;
} else {
delete field.source;
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source',
source,
'Currently supported source format: category:Category Name'
)
);
const sourcePrefix =
this.textInput(
field['source-prefix'] ||
''
);
sourcePrefix.on(
'input.preloadEditor',
() => {
const value =
sourcePrefix.val();
if (value) {
field[
'source-prefix'
] = value;
} else {
delete field[
'source-prefix'
];
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Source prefix',
sourcePrefix,
'Only source values beginning with this text are included.'
)
);
const excludeCategory =
this.textInput(
field.exclude &&
field.exclude.category ||
''
);
excludeCategory.on(
'input.preloadEditor',
() => {
const value =
excludeCategory.val();
if (value) {
field.exclude =
Object.assign(
{},
field.exclude ||
{},
{
category:
value
}
);
} else if (
field.exclude
) {
delete field.exclude
.category;
if (
!Object.keys(
field.exclude
).length
) {
delete field.exclude;
}
}
this.markDirty();
this.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Exclude category',
excludeCategory
)
);
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.renderConfigPreviewBySelector();
}
);
card.append(
this.fieldRow(
'Manual options',
options,
'One per line. Optional label format: value | label. Leave empty when using a source.'
)
);
},
createTransformControls(
field,
property,
title
) {
const wrapper =
$('<div>')
.addClass(
'preload-editor-transform'
);
$('<h4>')
.text(title)
.appendTo(
wrapper
);
const existing =
field[property] &&
typeof field[property] ===
'object'
? field[property]
: {};
const transform =
this.select(
[
{
value: '',
label:
'No transformation'
},
{
value:
'remove-prefix',
label:
'Remove prefix'
},
{
value:
'remove-suffix',
label:
'Remove suffix'
},
{
value: 'replace',
label:
'Replace'
}
],
existing.transform ||
''
);
const prefix =
this.textInput(
existing.prefix ||
''
);
const suffix =
this.textInput(
existing.suffix ||
''
);
const from =
this.textInput(
existing.from ||
''
);
const to =
this.textInput(
existing.to ||
''
);
const 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.renderConfigPreviewBySelector();
};
transform.on(
'change.preloadEditor',
() => {
update();
renderInputs();
}
);
[
prefix,
suffix,
from,
to
].forEach(
input => {
input.on(
'input.preloadEditor',
update
);
}
);
controls.append(
this.fieldRow(
'Transformation',
transform
),
prefixRow,
suffixRow,
fromRow,
toRow
);
renderInputs();
wrapper.append(
controls
);
return wrapper;
},
// Config output
getCleanConfig() {
const config =
JSON.parse(
JSON.stringify(
this.state.config
)
);
const fields =
config.fields || {};
const orderedFields =
{};
/*
* PAGE is always first and automatic.
*/
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() {
const preview =
this.root &&
this.root.find(
'.preload-editor-config-preview'
);
if (
preview &&
preview.length
) {
this.renderConfigPreview(
preview
);
}
this.renderAnalysisBySelector();
},
markDirty() {
this.state.dirty =
true;
/*
* Any configuration change is also a
* local draft change.
*/
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;
}
/*
* Always pull the actual CodeMirror
* document before saving.
*/
this.state.preloadContent =
this.getEditorContent();
const validation =
this.validateBeforeSave();
if (
validation.errors.length
) {
this.showValidationErrors(
validation
);
/*
* Keep the local draft because the
* save did not complete.
*/
this.saveDraft();
return;
}
this.setStatus(
'Saving preload...',
'loading'
);
try {
const exists =
await this.pageExists(
title
);
await this.savePage(
title,
this.state.preloadContent,
exists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!exists
);
this.state.mode =
'edit';
this.state.dirty =
false;
/*
* The preload has now been explicitly
* saved, so its local recovery copy is
* no longer needed.
*/
this.clearDraft(
title
);
this.setStatus(
'Preload saved successfully.',
'success'
);
mw.notify(
'Preload saved.',
{
type:
'success'
}
);
} catch (
error
) {
console.error(
'Preload Editor save:',
error
);
/*
* Keep the local draft intact if
* the wiki save failed.
*/
this.saveDraft();
this.setStatus(
error.message ||
'Could not save preload.',
'error'
);
mw.notify(
error.message ||
'Could not save preload.',
{
type:
'error'
}
);
}
},
async saveAll() {
let title =
String(
this.state.preloadTitle ||
''
).trim();
if (!title) {
const result =
await this.createPromptDialog(
'Preload title',
'Enter the full title for the preload.'
);
if (!result) {
return;
}
title = result;
this.state.preloadTitle =
title;
}
/*
* Always use the actual CodeMirror
* document when saving.
*/
this.state.preloadContent =
this.getEditorContent();
/*
* Make sure the configuration is
* synchronized with anything newly
* typed into the preload.
*/
this.reanalyze();
const validation =
this.validateBeforeSave();
if (
validation.errors.length
) {
this.showValidationErrors(
validation
);
/*
* Keep the recovery copy.
*/
this.saveDraft();
return;
}
const configTitle =
title.replace(
/\/+$/,
''
) +
'/config.json';
this.setStatus(
'Saving preload and config...',
'loading'
);
try {
const preloadExists =
await this.pageExists(
title
);
await this.savePage(
title,
this.state.preloadContent,
preloadExists
? 'Updated preload with Preload Editor'
: 'Created preload with Preload Editor',
!preloadExists
);
const configExists =
await this.pageExists(
configTitle
);
await this.savePage(
configTitle,
this.getConfigJSON(),
configExists
? 'Updated preload config with Preload Editor'
: 'Created preload config with Preload Editor',
!configExists
);
this.state.mode =
'edit';
this.state.dirty =
false;
this.state.preloadTitle =
title;
/*
* Both the preload and config are now
* safely on the wiki.
*/
this.clearDraft(
title
);
this.setStatus(
'Preload and config saved successfully.',
'success'
);
mw.notify(
'Preload and config saved.',
{
type:
'success'
}
);
} catch (
error
) {
console.error(
'Preload Editor save:',
error
);
/*
* IMPORTANT:
* if either wiki save fails, retain the
* browser draft so nothing is lost.
*/
this.saveDraft();
this.setStatus(
error.message ||
'Could not save preload/config.',
'error'
);
mw.notify(
error.message ||
'Could not save preload/config.',
{
type:
'error'
}
);
}
},
validateBeforeSave() {
const errors = [];
const warnings = [];
const title =
String(
this.state.preloadTitle ||
''
).trim();
if (!title) {
errors.push(
'A preload title is required.'
);
}
if (
title &&
!/\/Preload$/i.test(
title
)
) {
warnings.push(
'The title does not end with "/Preload".'
);
}
const content =
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
)
);
/*
* Synchronize configuration one final
* time before saving.
*/
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();
/*
* Keep the local recovery copy because
* validation prevented the wiki save.
*/
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));