MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| (6 intermediate revisions by the same user not shown) | |||
| Line 3: | Line 3: | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: ' | version: '1.2.0', | ||
state: { | state: { | ||
| Line 17: | Line 17: | ||
dirty: false, | dirty: false, | ||
dirtyPreload: false, | dirtyPreload: false, | ||
dirtyConfig: false | dirtyConfig: false, | ||
// Local draft state | |||
localDraftSavedAt: null, | |||
localDraftAvailable: false | |||
}, | }, | ||
| Line 26: | Line 30: | ||
workspace: null, | workspace: null, | ||
status: null, | status: null, | ||
localDraftStatus: null, | |||
editorTools: null, | editorTools: null, | ||
draftStorageKey: | draftStorageKey: 'PetitPlanet.PreloadEditor.drafts.v1', | ||
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 52: | 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 66: | Line 63: | ||
} | } | ||
if ( | if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) { | ||
parsed.drafts = {}; | parsed.drafts = {}; | ||
} | } | ||
| Line 76: | 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 90: | 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 107: | Line 89: | ||
getDraftId(title) { | getDraftId(title) { | ||
title = | title = String(title || '').trim(); | ||
return title ? title.toLowerCase() : '__untitled__'; | |||
return title | |||
}, | }, | ||
getDraftSnapshot() { | getDraftSnapshot() { | ||
let content = | let content = this.getEditorContent(); | ||
if ( | if (!this.editorView && !content) { | ||
content = this.state.preloadContent || ''; | |||
content = | |||
} | } | ||
| Line 131: | 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) { | |||
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() { | saveDraft() { | ||
if (!this.state.dirty) { | if (!this.state.dirty) { | ||
return; | return false; | ||
} | } | ||
const snapshot = | const snapshot = this.getDraftSnapshot(); | ||
const id = this.getDraftId(snapshot.preloadTitle); | |||
const storage = this.getDraftStorage(); | |||
storage.drafts[id] = snapshot; | |||
storage.active = id; | |||
const | 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); | |||
const id = | |||
if ( | if (storage.drafts && storage.drafts[id]) { | ||
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 ( | if (storage.active && storage.drafts[storage.active]) { | ||
return { | return { | ||
id: storage.active, | id: storage.active, | ||
draft: | draft: storage.drafts[storage.active] | ||
}; | }; | ||
} | } | ||
| Line 296: | Line 257: | ||
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 (!latestDraft) { | if (!latestDraft) { | ||
| Line 337: | Line 282: | ||
async 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 | const savedAt = draft.savedAt | ||
? new Date(draft.savedAt).toLocaleString() | |||
: ''; | |||
const message = | const message = | ||
'Preload Editor found unsaved local work for ' + | 'Preload Editor found unsaved local work for ' + | ||
displayTitle + | displayTitle + | ||
( | (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) { | ||
| Line 390: | Line 314: | ||
} | } | ||
let config = | let config = this.emptyConfig(); | ||
if ( | if ( | ||
| Line 399: | Line 322: | ||
) { | ) { | ||
try { | try { | ||
config = | config = this.normalizeConfig( | ||
JSON.parse(JSON.stringify(draft.config)) | |||
); | |||
} catch (error) { | } catch (error) { | ||
console.warn( | console.warn( | ||
| Line 420: | Line 338: | ||
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.dirtyConfig | |||
: true, | |||
localDraftSavedAt: Number(draft.savedAt) || null, | |||
localDraftAvailable: true | |||
}; | }; | ||
this.reanalyze(); | this.reanalyze(); | ||
this.renderEditor(); | this.renderEditor(); | ||
this.updateLocalDraftIndicator(); | |||
this.setStatus( | this.setStatus( | ||
| Line 504: | 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 = | const response = await new mw.Api().get({ | ||
action: 'query', | |||
prop: 'info|revisions', | |||
titles: title, | |||
rvprop: 'content', | |||
rvslots: 'main', | |||
formatversion: 2 | |||
}); | |||
const page = | const page = | ||
| Line 545: | Line 425: | ||
return { | return { | ||
title: | title: page.title || title, | ||
content: typeof content === 'string' ? content : '', | |||
contentmodel: page.contentmodel || 'wikitext' | |||
content: | |||
}; | }; | ||
}, | }, | ||
| Line 561: | Line 436: | ||
async searchPages(query) { | async searchPages(query) { | ||
const response = | const response = await new mw.Api().get({ | ||
action: 'query', | |||
list: 'search', | |||
srsearch: query, | |||
srlimit: 10, | |||
srnamespace: 10, | |||
formatversion: 2 | |||
}); | |||
return ( | return ( | ||
| Line 578: | 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 598: | Line 473: | ||
} | } | ||
return new mw.Api().postWithToken( | if (contentModel) { | ||
params.contentmodel = contentModel; | |||
} | |||
return new mw.Api().postWithToken('csrf', params); | |||
}, | }, | ||
// Preload | /* | ||
* 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' | |||
}); | |||
}, | |||
/* | |||
const | * 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 | 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'; | ||
}, | }, | ||
/* | |||
const | * 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); | |||
const | |||
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 | 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(); | |||
/* | |||
this. | * 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 | |||
return | ? 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 ( | if ( | ||
!config || | |||
typeof config !== 'object' || | |||
Array.isArray(config) | |||
) { | ) { | ||
config = this.emptyConfig(); | |||
} | } | ||
if ( | 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 | 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 ( | if ( | ||
! | !config || | ||
! | typeof config !== 'object' || | ||
Array.isArray(config) | |||
) { | ) { | ||
return | 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')) { | |||
if ( | 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 | |||
// ========================================================= | |||
if (! | transformationObject( | ||
return; | transform, | ||
prefix, | |||
suffix, | |||
from, | |||
to | |||
) { | |||
if (!transform) { | |||
return null; | |||
} | } | ||
switch (transform) { | |||
case 'remove-prefix': | |||
return prefix | |||
? { | |||
transform, | |||
prefix | |||
} | |||
: null; | |||
case 'remove-suffix': | |||
return suffix | |||
? { | |||
transform, | |||
suffix | |||
} | |||
: null; | |||
case 'replace': | |||
' | return from === '' | ||
? null | |||
: { | |||
transform, | |||
from, | |||
to: to || '' | |||
}; | |||
default: | |||
return null; | |||
} | |||
}, | }, | ||
// ========================================================= | |||
// UI helpers | |||
// ========================================================= | |||
createSection(title, className) { | |||
const section = $('<section>') | |||
.addClass('preload-editor-section'); | |||
if (className) { | |||
section.addClass(className); | |||
if ( | |||
} | } | ||
$('<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 | 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( | .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 => { | |||
$('<div>') | const overlay = $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-prompt-overlay' | ||
); | ); | ||
const box = $('<div>') | |||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-prompt' | ||
); | ); | ||
$('<h2>') | |||
.text(title) | |||
.appendTo(box); | |||
() | $('<p>') | ||
.text(message) | |||
.appendTo(box); | |||
const input = this.textInput(); | |||
const buttons = $('<div>') | |||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-prompt-buttons' | ||
); | ); | ||
const cancel = this.createButton( | |||
. | 'Cancel', | ||
'preload-editor- | '', | ||
() => { | |||
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( | .addClass( | ||
'preload-editor- | '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'; | |||
const | |||
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 | 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 | 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({ | 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. | 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 | const variables = | ||
this. | this.getToolVariables(); | ||
const current = | |||
this.editorTools | |||
. | .variableSelect | ||
.val(); | |||
. | |||
this. | 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 | |||
); | |||
populate( | |||
. | this.editorTools.conditionVariableSelect, | ||
currentCondition | |||
); | ); | ||
}, | }, | ||
updateConditionVariableVisibility() { | |||
if (!this.editorTools) { | |||
if (!this. | |||
return; | return; | ||
} | } | ||
this. | 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. | 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( | |||
'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 | 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 | |||
// ========================================================= | |||
const | 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 | const loader = | ||
this.createSection( | this.createSection('Preload'); | ||
const | 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 | const newButton = | ||
this. | this.createButton( | ||
' | 'New', | ||
'', | |||
() => this.startNew() | |||
); | ); | ||
const | const sampleButton = | ||
this.createButton( | 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>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-workspace' | ||
); | ); | ||
this.root.append( | |||
this. | 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- | 'preload-editor-status' | ||
); | |||
footer.append( | |||
this.localDraftStatus, | |||
this.status | |||
); | ); | ||
this.root.append( | |||
footer | |||
); | |||
this. | this.updateLocalDraftIndicator(); | ||
this. | 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); | |||
}, | }, | ||
setStatus( | |||
message, | |||
type | |||
) { | |||
if (!this.status) { | |||
return; | |||
} | |||
this. | this.status.removeClass( | ||
'is-error', | |||
'is-success', | |||
'is-warning', | |||
'is-loading' | |||
); | |||
if (type) { | |||
this.status.addClass( | |||
'is-' + type | |||
); | |||
} | |||
this. | this.status.text( | ||
message || '' | |||
' | |||
); | ); | ||
}, | }, | ||
// ========================================================= | |||
// Load / create | |||
// ========================================================= | |||
async loadExisting(title) { | |||
title = | |||
String(title || '').trim(); | |||
if (!title) { | |||
mw.notify( | |||
'Enter a preload page title first.', | |||
{ | |||
type: 'warn' | |||
} | |||
); | |||
return; | |||
} | |||
this. | this.setStatus( | ||
'Loading preload...', | |||
'loading' | |||
); | |||
this. | 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) { | ||
this. | 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; | |||
} | |||
const | 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>') | $('<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' | |||
); | |||
}, | |||
const | async loadSamplePage() { | ||
this. | 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 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 = | |||
const | this.analyzePreload(content); | ||
this. | |||
this. | 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(); | |||
this. | |||
this. | |||
}, | }, | ||
// ========================================================= | |||
// Editor | |||
// ========================================================= | |||
renderEditor() { | |||
this.destroyEditor(); | |||
this.workspace.empty(); | |||
const layout = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-layout' | |||
); | |||
const | const left = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-left' | |||
); | |||
const | const right = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-right' | |||
); | |||
const | const editorSection = | ||
this. | 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>') | |||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-badge' | ||
) | ) | ||
.text( | .text('NEW') | ||
.appendTo(meta); | |||
.appendTo( | |||
} | } | ||
editorSection.append(meta); | |||
this. | editorSection.append( | ||
this.renderEditorTools() | |||
); | |||
const textarea = | |||
this.textarea( | |||
this.state.preloadContent | |||
) | ) | ||
.attr( | |||
'id', | |||
'preload-editor-content' | |||
) | |||
.addClass( | |||
'preload-editor-content' | |||
); | |||
const editorMount = | |||
const | |||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-codemirror-mount' | ||
) | ) | ||
. | .append(textarea); | ||
editorSection.append(editorMount); | |||
left.append(editorSection); | |||
const analysisSection = | |||
this.createSection('Analysis'); | |||
const | const analysisContainer = | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-analysis' | ||
); | ); | ||
analysisSection.append( | |||
analysisContainer | |||
); | ); | ||
const variablesSection = | |||
this.createSection('Variables'); | |||
const detectVariablesButton = | |||
. | this.createButton( | ||
'preload-editor- | 'Detect variables', | ||
'preload-editor-button-primary preload-editor-detect-variables', | |||
() => | |||
this.detectVariables() | |||
); | ); | ||
const | const detectHelp = | ||
$('<div>') | |||
.addClass( | .addClass( | ||
'preload-editor- | '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 | const configurationSection = | ||
this. | this.createSection( | ||
'Variable configuration' | |||
); | ); | ||
const | /* | ||
this. | * 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 = | |||
const | |||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-help' | ||
) | ) | ||
. | .text( | ||
' | 'Review the exact config.json that will be uploaded to the wiki.' | ||
); | ); | ||
const | 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>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | '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( | |||
this. | 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() { | ||
const source = | |||
this.getEditorContent(); | |||
this.state.preloadContent = | |||
source; | |||
const | const content = | ||
this. | this.extractIncludeonly( | ||
source | |||
); | ); | ||
const analysis = | |||
this.analyzePreload( | |||
content | |||
); | |||
this. | /* | ||
* 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. | 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() { | |||
this. | const source = | ||
this.getEditorContent(); | |||
const content = | |||
this.extractIncludeonly(source); | |||
const | const analysis = | ||
this. | this.analyzePreload( | ||
content | |||
); | ); | ||
this.state.preloadContent = | |||
source; | |||
this.state.variables = | |||
analysis.variables; | |||
this.state.conditions = | |||
analysis.conditions; | |||
this.state.config = | |||
this. | this.prepareConfigForVariables( | ||
this.state.config || | |||
this.emptyConfig(), | |||
analysis | |||
); | |||
const | 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. | 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 | 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 ( | 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 ( | if ( | ||
container && | |||
container.length | |||
) { | ) { | ||
this. | 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( | |||
const | this.state.config.fields | ||
this. | ) | ||
: []; | |||
configuredFields.forEach( | |||
name => { | |||
const upper = | |||
const | name.toUpperCase(); | ||
if ( | if ( | ||
upper !== 'PAGE' && | |||
!ordered.includes(upper) | |||
) { | |||
ordered.push(upper); | |||
} | } | ||
} | } | ||
); | ); | ||
const deleted = | |||
Array.isArray( | |||
this.state.deletedVariables | |||
) | ) | ||
? this.state.deletedVariables | |||
: []; | |||
const | 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 | ); | ||
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() { | ||
this. | const container = | ||
this.root && | |||
this.root.find( | |||
'preload-editor- | '.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' | ||
const | ); | ||
. | 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 | const 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; | |||
} | |||
const | if (!field) { | ||
const missing = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable preload-editor-variable-missing' | |||
); | |||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | '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. | this.createVariableCard( | ||
name, | |||
field, | |||
); | analysis | ||
) | |||
); | |||
}, | |||
const | 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( | 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; | |||
}, | |||
const | createVariableCard( | ||
name, | |||
field, | |||
analysis | |||
) { | |||
const card = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable' | |||
) | |||
.attr( | |||
'data-variable', | |||
name | |||
); | |||
const | const header = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-header' | |||
); | |||
header.append( | |||
$('<h3>').text(name) | |||
); | |||
if ( | |||
. | !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( | |||
this. | '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. | this.fieldRow( | ||
'Required', | |||
required | |||
) | |||
); | |||
const placeholder = | |||
this.textInput( | |||
field.placeholder || '' | |||
const | |||
this. | |||
); | ); | ||
placeholder.on( | |||
'input.preloadEditor', | |||
() => { | |||
const value = | |||
placeholder.val(); | |||
if (value) { | |||
field.placeholder = | |||
value; | |||
} else { | |||
delete field.placeholder; | |||
} | |||
this.markDirty(); | |||
this.renderConfigReviewAvailability(); | |||
} | |||
); | |||
this. | 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. | this.renderConfigReviewAvailability(); | ||
this. | |||
} | } | ||
); | |||
this. | 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 | |||
this. | |||
); | ); | ||
} | } | ||
if ( | |||
field.type === 'file' | |||
) { | |||
this.renderFileConfiguration( | |||
card, | |||
field | |||
); | ); | ||
} | } | ||
const | 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 ( | |||
this.state. | !Array.isArray( | ||
this.state.deletedVariables | |||
) | |||
) { | |||
this.state.deletedVariables = []; | |||
} | |||
) | |||
if ( | |||
!this.state.deletedVariables.includes( | |||
this. | 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, | |||
const | field | ||
this. | ) { | ||
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', | |||
this. | () => { | ||
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 || | |||
'' | |||
); | ); | ||
const | excludeCategory.on( | ||
await this. | '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( | await this.savePage( | ||
configTitle, | title, | ||
this.getConfigJSON(), | this.state.preloadContent, | ||
exists | |||
? 'Updated preload config with Preload Editor' | ? 'Updated preload with Preload Editor' | ||
: 'Created preload config with Preload Editor', | : 'Created preload with Preload Editor', | ||
!configExists | !exists | ||
); | ); | ||
this.state.mode = 'edit'; | this.state.mode = 'edit'; | ||
this.state.dirtyPreload = false; | this.state.preloadTitle = title; | ||
this.state.dirtyConfig = false; | this.state.dirtyPreload = false; | ||
this.state.dirty = false; | |||
this.state.preloadTitle = title; | this.updateDirtyState(); | ||
this.clearDraft(title); | if (!this.state.dirty) { | ||
this.clearDraft(title); | |||
this.setStatus( | } else { | ||
'Preload and config saved successfully.', | this.saveDraft(); | ||
'success' | } | ||
); | |||
this.setStatus( | |||
mw.notify( | this.state.dirtyConfig | ||
'Preload and config saved.', | ? 'Preload saved. Config still has unsaved changes.' | ||
{ | : 'Preload saved successfully.', | ||
type: 'success' | 'success' | ||
} | ); | ||
); | |||
} catch (error) { | mw.notify( | ||
console.error( | this.state.dirtyConfig | ||
'Preload Editor save:', | ? 'Preload saved. Config still has unsaved changes.' | ||
error | : 'Preload saved.', | ||
); | { | ||
type: 'success' | |||
this.saveDraft(); | } | ||
); | |||
} 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() { | validateBeforeSave() { | ||
| Line 5,524: | Line 6,142: | ||
let depth = 0; | let depth = 0; | ||
analysis.conditions.forEach( | |||
condition => { | condition => { | ||
if ( | if ( | ||
| Line 5,597: | Line 6,214: | ||
if ( | if ( | ||
mw.config.get('wgPageName') !== | mw.config.get('wgPageName') !== | ||
' | 'Petit_Planet_Wiki:Preload_Editor' | ||
) { | ) { | ||
return; | return; | ||