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