MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| (3 intermediate revisions by the same user not shown) | |||
| Line 3: | Line 3: | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: '1. | version: '1.2.0', | ||
state: { | state: { | ||
| Line 33: | Line 33: | ||
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' | ||
}, | }, | ||
| Line 47: | Line 45: | ||
getDraftStorage() { | getDraftStorage() { | ||
try { | try { | ||
const raw = | const raw = window.localStorage.getItem(this.draftStorageKey); | ||
if (!raw) { | if (!raw) { | ||
| Line 59: | 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 73: | Line 63: | ||
} | } | ||
if ( | if (!parsed.drafts || typeof parsed.drafts !== 'object' || Array.isArray(parsed.drafts)) { | ||
parsed.drafts = {}; | parsed.drafts = {}; | ||
} | } | ||
| Line 83: | 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 97: | 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 114: | 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 138: | 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: 3, | version: 3, | ||
preloadTitle: String(this.state.preloadTitle || ''), | |||
preloadTitle: | preloadSource: String(this.state.preloadSource || ''), | ||
preloadContent: String(content || ''), | |||
preloadSource: | |||
preloadContent: | |||
config, | config, | ||
variables: | variables: Array.isArray(this.state.variables) | ||
? this.state.variables.slice() | |||
: [], | |||
conditions: Array.isArray(this.state.conditions) | |||
this.state. | ? this.state.conditions.slice() | ||
: [], | |||
selectedVariable: String(this.state.selectedVariable || 'PAGE'), | |||
deletedVariables: Array.isArray(this.state.deletedVariables) | |||
? this.state.deletedVariables.slice() | |||
: [], | |||
dirtyConfig: | mode: this.state.mode || 'edit', | ||
dirty: !!this.state.dirty, | |||
dirtyPreload: !!this.state.dirtyPreload, | |||
savedAt: | dirtyConfig: !!this.state.dirtyConfig, | ||
savedAt: Date.now() | |||
}; | }; | ||
}, | }, | ||
updateLocalDraftStatus( | updateLocalDraftStatus(message, type) { | ||
if (!this.localDraftStatus) { | if (!this.localDraftStatus) { | ||
return; | return; | ||
| Line 237: | Line 153: | ||
if (type) { | if (type) { | ||
this.localDraftStatus.addClass( | this.localDraftStatus.addClass('is-' + type); | ||
} | } | ||
this.localDraftStatus.text( | this.localDraftStatus.text(message || ''); | ||
}, | }, | ||
| Line 253: | Line 165: | ||
try { | try { | ||
return new Date( | return new Date(timestamp).toLocaleTimeString([], { | ||
hour: '2-digit', | |||
minute: '2-digit', | |||
second: '2-digit' | |||
}); | |||
} catch (error) { | } catch (error) { | ||
return ''; | return ''; | ||
| Line 273: | Line 180: | ||
} | } | ||
if ( | if (this.state.localDraftSavedAt) { | ||
const time = this.formatLocalDraftTime(this.state.localDraftSavedAt); | |||
const time = | |||
this.updateLocalDraftStatus( | this.updateLocalDraftStatus( | ||
time | time ? 'Saved locally at ' + time : 'Saved locally', | ||
'saved' | 'saved' | ||
); | ); | ||
| Line 291: | Line 191: | ||
} | } | ||
this.updateLocalDraftStatus( | this.updateLocalDraftStatus('No local draft', 'empty'); | ||
}, | }, | ||
| Line 302: | Line 199: | ||
} | } | ||
const snapshot = | const snapshot = this.getDraftSnapshot(); | ||
const id = this.getDraftId(snapshot.preloadTitle); | |||
const storage = this.getDraftStorage(); | |||
const id = | |||
const storage = | |||
storage.drafts[id] = snapshot; | |||
storage.active = id; | storage.active = id; | ||
const saved = | const saved = this.setDraftStorage(storage); | ||
if (saved) { | if (saved) { | ||
this.state.localDraftSavedAt = | this.state.localDraftSavedAt = snapshot.savedAt; | ||
this.state.localDraftAvailable = true; | |||
this.state.localDraftAvailable = | |||
this.updateLocalDraftIndicator(); | this.updateLocalDraftIndicator(); | ||
} else { | } else { | ||
this.state.localDraftAvailable = | this.state.localDraftAvailable = false; | ||
this.updateLocalDraftStatus('Could not save local draft', 'error'); | |||
this.updateLocalDraftStatus( | |||
} | } | ||
| Line 345: | Line 221: | ||
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.localDraftAvailable = | this.state.localDraftSavedAt = null; | ||
this.state.localDraftAvailable = false; | |||
this.updateLocalDraftIndicator(); | this.updateLocalDraftIndicator(); | ||
| Line 378: | Line 241: | ||
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 408: | 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 449: | 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 title = | |||
const displayTitle = | |||
const savedAt = | 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 502: | Line 314: | ||
} | } | ||
let config = | let config = this.emptyConfig(); | ||
if ( | if ( | ||
| Line 511: | Line 322: | ||
) { | ) { | ||
try { | try { | ||
config = | config = this.normalizeConfig( | ||
JSON.parse(JSON.stringify(draft.config)) | |||
); | |||
} catch (error) { | } catch (error) { | ||
console.warn( | console.warn( | ||
| Line 532: | 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) | |||
String( | ? 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 | |||
}; | |||
localDraftSavedAt: | |||
localDraftAvailable: | |||
}; | |||
this.reanalyze(); | this.reanalyze(); | ||
this.renderEditor(); | this.renderEditor(); | ||
this.updateLocalDraftIndicator(); | this.updateLocalDraftIndicator(); | ||
| Line 626: | Line 386: | ||
); | ); | ||
mw.notify( | mw.notify('Unsaved Preload Editor work restored.', { | ||
type: 'success' | |||
}); | |||
return true; | return true; | ||
| Line 641: | Line 398: | ||
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 669: | Line 425: | ||
return { | return { | ||
title: | title: page.title || title, | ||
content: typeof content === 'string' ? content : '', | |||
contentmodel: page.contentmodel || 'wikitext' | |||
content: | |||
contentmodel: | |||
}; | }; | ||
}, | }, | ||
| Line 689: | 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 714: | Line 460: | ||
* changing, use changeContentModel() first. | * changing, use changeContentModel() first. | ||
*/ | */ | ||
async savePage( | 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 736: | Line 474: | ||
if (contentModel) { | if (contentModel) { | ||
params.contentmodel = | params.contentmodel = contentModel; | ||
} | } | ||
return new mw.Api().postWithToken( | return new mw.Api().postWithToken('csrf', params); | ||
}, | }, | ||
| Line 754: | Line 488: | ||
* The page must already exist. | * The page must already exist. | ||
*/ | */ | ||
async changeContentModel( | async changeContentModel(title, model, summary) { | ||
return new mw.Api().postWithToken('csrf', { | |||
action: 'changecontentmodel', | |||
title, | |||
model, | |||
return new mw.Api().postWithToken( | reason: summary || 'Changed content model with Preload Editor' | ||
}); | |||
}, | }, | ||
| Line 787: | Line 508: | ||
* then save the JSON normally. | * then save the JSON normally. | ||
*/ | */ | ||
async saveConfigPage( | async saveConfigPage(title, content, summary) { | ||
const page = await this.getPage(title); | |||
const page = | |||
if (!page) { | if (!page) { | ||
return this.savePage( | return this.savePage( | ||
| Line 805: | Line 521: | ||
} | } | ||
if ( | if (String(page.contentmodel || '').toLowerCase() !== 'json') { | ||
await this.changeContentModel( | await this.changeContentModel( | ||
title, | title, | ||
| Line 841: | Line 553: | ||
*/ | */ | ||
normalizeConfigTitle(title) { | normalizeConfigTitle(title) { | ||
title = | title = String(title || '').trim(); | ||
title = title.replace(/\/+$/, ''); | |||
title = | |||
if ( | if (/\/config\.json$/i.test(title)) { | ||
return title; | return title; | ||
} | } | ||
return title + | return title + '/config.json'; | ||
}, | }, | ||
| Line 870: | Line 571: | ||
cloneConfigField(field) { | cloneConfigField(field) { | ||
try { | try { | ||
return JSON.parse( | return JSON.parse(JSON.stringify(field)); | ||
} catch (error) { | } catch (error) { | ||
console.warn( | console.warn( | ||
| Line 888: | Line 587: | ||
*/ | */ | ||
getImportableFields(sourceConfig) { | getImportableFields(sourceConfig) { | ||
const fields = | const fields = sourceConfig && sourceConfig.fields; | ||
if ( | if ( | ||
| Line 902: | Line 599: | ||
const result = {}; | const result = {}; | ||
Object.entries( | 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; | return result; | ||
| Line 954: | Line 635: | ||
* [...] -> selected field names | * [...] -> selected field names | ||
*/ | */ | ||
createImportDefinitionsDialog( | createImportDefinitionsDialog(sourceTitle, fields) { | ||
return new Promise(resolve => { | |||
const names = Object.keys(fields).sort((a, b) => a.localeCompare(b)); | |||
return new Promise( | 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( | .text( | ||
' | 'This config.json does not contain any importable variable definitions.' | ||
) | ) | ||
.appendTo(box); | .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 | 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 | |||
selected | .find('input[type="checkbox"]:checked') | ||
.each(function () { | |||
selected.push( | |||
String( | |||
$(this).attr( | |||
'data-import-variable' | |||
) | ) | ||
) | |||
); | |||
}); | |||
overlay.remove(); | |||
resolve(selected); | |||
} | |||
); | |||
buttons.append(cancel, importButton); | |||
box.append(summary, buttons); | |||
overlay.append(box); | |||
$(document.body).append(overlay); | |||
overlay.attr('tabindex', '-1'); | |||
overlay.trigger('focus'); | |||
updateSummary(); | |||
search.trigger('focus'); | |||
const close = event => { | |||
if (event.key === 'Escape') { | |||
cancel.trigger('click'); | |||
} | |||
}; | |||
overlay.on( | |||
'keydown.preloadEditor', | |||
close | |||
); | |||
overlay.on( | |||
'click.preloadEditor', | |||
event => { | |||
); | if (event.target === overlay.get(0)) { | ||
cancel.trigger('click'); | |||
} | |||
} | |||
); | |||
}); | |||
}, | |||
/* | |||
* Main import workflow. | |||
*/ | |||
async importVariableDefinitions() { | |||
const source = await this.createPromptDialog( | |||
'Import variable definitions', | |||
'Enter a preload title or its config.json title.' | |||
); | |||
if (!source) { | |||
return; | |||
} | |||
const configTitle = this.normalizeConfigTitle(source); | |||
this.setStatus( | |||
'Loading source config...', | |||
'loading' | |||
); | |||
try { | |||
const page = await this.getPage(configTitle); | |||
if (!page) { | |||
throw new Error( | |||
'No config.json was found at "' + | |||
configTitle + | |||
'".' | |||
); | ); | ||
} | |||
let sourceConfig; | |||
try { | |||
sourceConfig = JSON.parse(page.content); | |||
} catch (error) { | |||
throw new Error( | |||
'The source config.json contains invalid JSON: ' + | |||
error.message | |||
); | ); | ||
} | |||
if ( | |||
!sourceConfig || | |||
typeof sourceConfig !== 'object' || | |||
Array.isArray(sourceConfig) | |||
) { | |||
throw new Error( | |||
'The source config.json must contain a JSON object.' | |||
); | |||
} | |||
if ( | |||
!sourceConfig.fields || | |||
typeof sourceConfig.fields !== 'object' || | |||
Array.isArray(sourceConfig.fields) | |||
' | ) { | ||
throw new Error( | |||
'The source config.json does not contain a valid "fields" object.' | |||
); | ); | ||
} | |||
const fields = this.getImportableFields(sourceConfig); | |||
const selected = | |||
await this.createImportDefinitionsDialog( | |||
configTitle, | |||
fields | |||
); | |||
if (selected === null) { | |||
this.setStatus( | |||
'Variable import cancelled.', | |||
'warning' | |||
); | |||
return; | |||
} | |||
if (!selected.length) { | |||
' | this.setStatus( | ||
'No variable definitions were imported.', | |||
); | 'warning' | ||
); | |||
return; | |||
} | |||
if (!this.state.config) { | |||
this.state.config = this.emptyConfig(); | |||
} | } | ||
if (!this.state.config.fields) { | |||
this.state.config.fields = {}; | |||
} | |||
const imported = []; | |||
const replaced = []; | |||
selected.forEach(name => { | |||
if (name === 'PAGE') { | |||
return; | |||
} | |||
const field = fields[name]; | |||
if (!field) { | |||
return; | |||
} | |||
if (this.state.config.fields[name]) { | |||
replaced.push(name); | |||
} else { | |||
imported.push(name); | |||
} | |||
const clone = this.cloneConfigField(field); | |||
if (!clone) { | |||
return; | |||
} | |||
this.state.config.fields[name] = clone; | |||
/* | |||
* If this variable was previously deleted, | |||
* importing its definition explicitly should | |||
Array.isArray( | * restore it. | ||
*/ | |||
if (Array.isArray(this.state.deletedVariables)) { | |||
this.state.deletedVariables = | |||
this.state.deletedVariables.filter( | |||
} | deletedName => | ||
String(deletedName).toUpperCase() !== name | |||
); | |||
} | |||
}); | |||
/* | |||
this. | * Keep PAGE automatic regardless of what the | ||
* imported config contained. | |||
*/ | |||
this.state.config.fields.PAGE = | |||
this.getAutomaticPageField(); | |||
const | this.markConfigDirty(); | ||
/* | |||
* Keep the current selection when possible. | |||
* Otherwise select the first imported definition. | |||
*/ | |||
const selectedStillExists = !!( | |||
this.state.config.fields[ | |||
this.state.selectedVariable | |||
] | |||
); | |||
if ( | if ( | ||
!selectedStillExists || | |||
this.state.selectedVariable === 'PAGE' | |||
) { | ) { | ||
this. | this.state.selectedVariable = | ||
selected[0] || | |||
' | 'PAGE'; | ||
} | } | ||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.updateEditorTools(); | |||
this.saveDraft(); | |||
let status = | |||
'Imported ' + | |||
selected.length + | |||
' variable ' + | |||
(selected.length === 1 | |||
? 'definition' | |||
: 'definitions') + | |||
'.'; | |||
if (replaced.length) { | |||
status += | |||
' Replaced: ' + | |||
replaced.join(', ') + | |||
'.'; | |||
} | |||
this.setStatus(status, 'success'); | |||
mw.notify(status, { | |||
type: 'success' | |||
}); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor variable import:', | |||
error | |||
); | |||
this.setStatus( | |||
error.message || | |||
) | 'Could not import variable definitions.', | ||
'error' | |||
); | |||
mw.notify( | |||
error.message || | |||
'Could not import variable definitions.', | |||
{ | |||
type: 'error' | |||
} | |||
); | |||
} | |||
}, | |||
// ========================================================= | |||
// Preload parsing | |||
// ========================================================= | |||
extractIncludeonly(source) { | |||
const match = String(source || '').match( | |||
/<includeonly>([\s\S]*?)<\/includeonly>/i | |||
); | |||
return match | |||
? match[1].trim() | |||
: String(source || '').trim(); | |||
}, | |||
findVariables(content) { | |||
const variables = []; | |||
const pattern = /%%([A-Za-z0-9_-]+)%%/g; | |||
let match; | |||
while ((match = pattern.exec(content)) !== null) { | |||
const name = match[1].toUpperCase(); | |||
if (!variables.includes(name)) { | |||
variables.push(name); | |||
if ( | |||
} | } | ||
} | |||
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 ( | 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; | |||
}, | |||
this. | analyzePreload(content) { | ||
return { | |||
variables: this.findVariables(content), | |||
conditions: this.findConditions(content), | |||
conditionVariables: | |||
this.findConditionVariables(content) | |||
}; | |||
} | |||
}, | }, | ||
// ========================================================= | // ========================================================= | ||
// | // Config | ||
// ========================================================= | // ========================================================= | ||
getAutomaticPageField() { | |||
return { | |||
label: 'Page Name', | |||
description: 'Name of the page.', | |||
type: 'page', | |||
required: true, | |||
namespace: 'Main', | |||
'allow-new': true | |||
}; | |||
}, | }, | ||
emptyConfig() { | |||
return { | |||
settings: { | |||
incomplete: 'add-stub' | |||
}, | |||
fields: { | |||
PAGE: this.getAutomaticPageField() | |||
} | |||
}; | |||
}, | |||
normalizeConfig(config) { | |||
if ( | |||
!config || | |||
typeof config !== 'object' || | |||
Array.isArray(config) | |||
) { | ) { | ||
config = this.emptyConfig(); | |||
} | |||
if ( | |||
!config.settings || | |||
typeof config.settings !== 'object' || | |||
Array.isArray(config.settings) | |||
) { | |||
config.settings = {}; | |||
} | } | ||
if ( | |||
!config.fields || | |||
typeof config.fields !== 'object' || | |||
Array.isArray(config.fields) | |||
( | |||
) { | ) { | ||
config.fields = {}; | |||
} | |||
config.fields.PAGE = | |||
this.getAutomaticPageField(); | |||
return config; | |||
}, | |||
return | createDefaultField(name, analysis) { | ||
return { | |||
label: this.humanizeName(name), | |||
description: '', | |||
type: analysis.conditionVariables.includes(name) | |||
? 'boolean' | |||
: 'text' | |||
}; | |||
}, | }, | ||
humanizeName(name) { | |||
const | 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 || | !config || | ||
| Line 1,902: | Line 1,378: | ||
Array.isArray(config) | Array.isArray(config) | ||
) { | ) { | ||
return { | |||
valid: false, | |||
errors: [ | |||
'Config must be an object.' | |||
], | |||
warnings: [] | |||
}; | |||
} | } | ||
| Line 1,919: | Line 1,392: | ||
Array.isArray(config.fields) | Array.isArray(config.fields) | ||
) { | ) { | ||
return { | |||
} | valid: false, | ||
errors: [ | |||
'"fields" must be an object.' | |||
], | |||
warnings | |||
}; | |||
} | |||
const configured = | |||
Object.keys( | |||
config.fields | |||
).map( | |||
name => | |||
name.toUpperCase() | |||
); | |||
analysis.variables.forEach(name => { | |||
if (!configured.includes(name)) { | |||
errors.push( | |||
'Variable "' + | |||
name + | |||
'" is not configured.' | |||
); | |||
} | |||
}); | |||
configured.forEach(name => { | |||
if (!analysis.variables.includes(name)) { | |||
warnings.push( | |||
'Field "' + | |||
name + | |||
'" is not used by the preload.' | |||
); | |||
} | |||
}); | |||
if (!configured.includes('PAGE')) { | |||
errors.push( | |||
'PAGE must always be configured.' | |||
); | |||
} | |||
Object.entries(config.fields).forEach(([name, field]) => { | |||
if ( | |||
!field || | |||
typeof field !== 'object' || | |||
Array.isArray(field) | |||
) { | |||
errors.push( | |||
'Field "' + | |||
name + | |||
'" must be an object.' | |||
); | |||
return; | |||
} | |||
if (!field.type) { | |||
warnings.push( | |||
'Field "' + | |||
name + | |||
'" has no type.' | |||
); | |||
} | |||
if ( | |||
field.type === 'file' | |||
) { | |||
if ( | 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( | |||
name | value => | ||
!String( | |||
value || '' | |||
).trim() | |||
) | |||
) { | |||
errors.push( | |||
'File field "' + | |||
name + | |||
'" contains an empty accepted MIME type.' | |||
); | ); | ||
} | |||
if ( | 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 ( | if ( | ||
! | field.accept !== undefined && | ||
( | |||
!Array.isArray(field.accept) || | |||
field.accept.some( | |||
value => | |||
typeof value !== 'string' || | |||
!value.trim() | |||
) | |||
) | |||
) { | ) { | ||
errors.push( | errors.push( | ||
' | 'File field "' + | ||
name + | name + | ||
'" | '" must have "accept" as an array of MIME types.' | ||
); | ); | ||
} | } | ||
} | } | ||
); | }); | ||
return { | |||
valid: !errors.length, | |||
errors, | |||
warnings | |||
return { | |||
valid: | |||
errors, | |||
warnings | |||
}; | }; | ||
}, | }, | ||
| Line 2,222: | Line 1,649: | ||
// ========================================================= | // ========================================================= | ||
createSection( | createSection(title, className) { | ||
const section = $('<section>') | |||
.addClass('preload-editor-section'); | |||
const section = | if (className) { | ||
if (className) { | |||
section.addClass(className); | section.addClass(className); | ||
} | } | ||
$('<h2>') | $('<h2>') | ||
.addClass( | .addClass('preload-editor-section-title') | ||
.text(title) | .text(title) | ||
.appendTo(section); | .appendTo(section); | ||
| Line 2,246: | Line 1,665: | ||
}, | }, | ||
createButton( | createButton(label, className, callback) { | ||
const button = $('<button>') | |||
.attr('type', 'button') | |||
.addClass('preload-editor-button') | |||
.text(label); | |||
const button = | |||
if (className) { | if (className) { | ||
| Line 2,276: | Line 1,685: | ||
}, | }, | ||
fieldRow( | fieldRow(label, input, description) { | ||
const row = $('<div>') | |||
.addClass('preload-editor-field'); | |||
const row = | |||
$('<label>') | $('<label>') | ||
.addClass( | .addClass('preload-editor-label') | ||
.text(label) | .text(label) | ||
.appendTo(row); | .appendTo(row); | ||
| Line 2,296: | Line 1,696: | ||
if (description) { | if (description) { | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass('preload-editor-help') | ||
.text(description) | .text(description) | ||
.appendTo(row); | .appendTo(row); | ||
| Line 2,308: | Line 1,706: | ||
}, | }, | ||
textInput( | textInput(value, placeholder) { | ||
return $('<input>') | return $('<input>') | ||
.attr({ | .attr({ | ||
type: 'text', | type: 'text', | ||
value: | value: value == null ? '' : value, | ||
placeholder: placeholder || '' | |||
placeholder: | |||
}) | }) | ||
.addClass( | .addClass('preload-editor-input'); | ||
}, | }, | ||
textarea(value) { | textarea(value) { | ||
return $('<textarea>') | return $('<textarea>') | ||
.val( | .val(value == null ? '' : value) | ||
.addClass('preload-editor-textarea'); | |||
.addClass( | |||
}, | }, | ||
checkbox(checked) { | checkbox(checked) { | ||
return $('<input>') | return $('<input>') | ||
.attr( | .attr('type', 'checkbox') | ||
.prop('checked', !!checked) | |||
.addClass('preload-editor-checkbox'); | |||
.prop( | |||
.addClass( | |||
}, | }, | ||
select( | select(options, value) { | ||
const select = $('<select>') | |||
.addClass('preload-editor-select'); | |||
const select = | options.forEach(option => { | ||
$('<option>') | |||
.attr('value', option.value) | |||
.text(option.label) | |||
.prop( | |||
'selected', | |||
options.forEach( | String(option.value) === | ||
String(value === undefined ? '' : value) | |||
) | |||
.appendTo(select); | |||
}); | |||
return select; | return select; | ||
| Line 2,403: | Line 1,758: | ||
cancelLabel | cancelLabel | ||
) { | ) { | ||
return new Promise( | return new Promise(resolve => { | ||
const overlay = $('<div>') | |||
.addClass('preload-editor-prompt-overlay'); | |||
const box = $('<div>') | |||
.addClass('preload-editor-prompt'); | |||
$('<h2>') | |||
.text(title) | |||
.appendTo(box); | |||
const messageLines = | |||
String(message || '').split('\n'); | |||
messageLines.forEach(line => { | |||
if (line === '') { | |||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor-prompt- | '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( | createPromptDialog(title, message) { | ||
return new Promise(resolve => { | |||
const overlay = $('<div>') | |||
.addClass( | |||
return new Promise( | 'preload-editor-prompt-overlay' | ||
); | |||
const box = $('<div>') | |||
.addClass( | |||
'preload-editor-prompt' | |||
); | |||
$('<h2>') | |||
.text(title) | |||
.appendTo(box); | |||
$('<p>') | |||
.text(message) | |||
.appendTo(box); | |||
const input = this.textInput(); | |||
const buttons = $('<div>') | |||
.addClass( | |||
'preload-editor-prompt-buttons' | |||
); | |||
const cancel = this.createButton( | |||
'Cancel', | |||
'', | |||
() => { | |||
overlay.remove(); | |||
resolve(null); | |||
} | |||
); | |||
const confirm = this.createButton( | |||
'Load', | |||
'preload-editor-button-primary', | |||
() => { | |||
const value = | |||
String( | |||
input.val() || '' | |||
).trim(); | |||
if (!value) { | |||
return; | |||
} | |||
overlay.remove(); | |||
resolve(value); | |||
} | |||
); | |||
buttons.append(cancel, confirm); | |||
box.append( | |||
input, | |||
buttons | |||
); | |||
overlay.append(box); | |||
$(document.body).append(overlay); | |||
input.trigger('focus'); | |||
input.on( | |||
'keydown.preloadEditor', | |||
event => { | |||
if (event.key === 'Enter') { | |||
confirm.trigger('click'); | |||
} | |||
if (event.key === 'Escape') { | |||
cancel.trigger('click'); | |||
} | } | ||
); | } | ||
); | |||
}); | |||
}, | }, | ||
createConfigReviewDialog() { | createConfigReviewDialog() { | ||
return new Promise( | return new Promise(resolve => { | ||
const overlay = $('<div>') | |||
.addClass( | |||
'preload-editor-prompt-overlay preload-editor-code-review-overlay' | |||
); | |||
const box = $('<div>') | |||
.addClass( | |||
'preload-editor-prompt preload-editor-code-review' | |||
); | |||
$('<h2>') | |||
.text('Review config.json') | |||
.appendTo(box); | |||
$('<p>') | |||
.text( | |||
'This is the config.json that will be saved for the current preload.' | |||
) | |||
.appendTo(box); | |||
const code = $('<pre>') | |||
.addClass( | |||
'preload-editor-config-review' | |||
) | |||
.text(this.getConfigJSON()); | |||
const codeWrap = $('<div>') | |||
.addClass( | |||
'preload-editor-config-review-wrap' | |||
) | |||
.append(code); | |||
box.append(codeWrap); | |||
const buttons = $('<div>') | |||
.addClass( | |||
'preload-editor-prompt-buttons' | |||
); | |||
const close = this.createButton( | |||
'Close', | |||
'preload-editor-button-primary', | |||
() => { | |||
overlay.remove(); | |||
resolve(); | |||
} | |||
); | |||
buttons.append(close); | |||
box.append(buttons); | |||
overlay.append(box); | |||
$(document.body).append(overlay); | |||
overlay.attr('tabindex', '-1'); | |||
overlay.trigger('focus'); | |||
overlay.on( | |||
'keydown.preloadEditor', | |||
event => { | |||
if (event.key === 'Escape') { | |||
close.trigger('click'); | |||
} | |||
} | |||
); | |||
overlay.on( | |||
'click.preloadEditor', | |||
event => { | |||
if (event.target === overlay.get(0)) { | |||
close.trigger('click'); | |||
} | } | ||
); | } | ||
); | |||
}); | |||
}, | |||
async confirmSave(mode, title) { | |||
async confirmSave( | |||
const configTitle = | const configTitle = | ||
title.replace( | title.replace(/\/+$/, '') + | ||
'/config.json'; | '/config.json'; | ||
| Line 2,740: | Line 2,023: | ||
} | } | ||
const pages = []; | |||
if ( | if (mode === 'preload' || mode === 'both') { | ||
pages.push( | pages.push( | ||
'Preload: ' + title | 'Preload: ' + | ||
title | |||
); | ); | ||
} | } | ||
if ( | if (mode === 'config' || mode === 'both') { | ||
pages.push( | pages.push( | ||
'Config: ' + configTitle | 'Config: ' + | ||
configTitle | |||
); | ); | ||
| Line 2,781: | Line 2,060: | ||
// CodeMirror | // CodeMirror | ||
// ========================================================= | // ========================================================= | ||
destroyEditor() { | |||
if (!this.editor) { | if (!this.editor) { | ||
this.editorView = null; | this.editorView = null; | ||
| Line 2,794: | Line 2,072: | ||
} = this.editor; | } = this.editor; | ||
if ( | if (view && inputHandler) { | ||
view.dom.removeEventListener( | view.dom.removeEventListener( | ||
'input', | 'input', | ||
| Line 2,825: | Line 2,100: | ||
} | } | ||
return | return this.state.preloadContent || ''; | ||
}, | }, | ||
setEditorContent(content) { | setEditorContent(content) { | ||
content = | content = String(content || ''); | ||
if (!this.editorView) { | if (!this.editorView) { | ||
this.state.preloadContent = | this.state.preloadContent = content; | ||
return; | return; | ||
} | } | ||
| Line 2,850: | Line 2,121: | ||
changes: { | changes: { | ||
from: 0, | from: 0, | ||
to: | to: this.editorView.state.doc.length, | ||
insert: content | insert: content | ||
} | } | ||
| Line 2,866: | Line 2,136: | ||
const selection = | const selection = | ||
this.editorView.state | this.editorView.state.selection.main; | ||
return { | return { | ||
from: | from: selection.from, | ||
to: selection.to, | |||
text: this.editorView.state.doc.sliceString( | |||
selection.from, | selection.from, | ||
selection.to | |||
) | |||
selection.to | |||
}; | }; | ||
}, | }, | ||
| Line 2,900: | Line 2,164: | ||
} | } | ||
const view = | const view = this.editorView; | ||
const selection = view.state.selection.main; | |||
const selection = | |||
const from = | const from = | ||
| Line 2,927: | Line 2,188: | ||
insert: text | insert: text | ||
}, | }, | ||
selection: { | selection: { | ||
anchor: cursor | anchor: cursor | ||
| Line 2,961: | Line 2,221: | ||
selected.from, | selected.from, | ||
selected.to, | selected.to, | ||
selected.from + | selected.from + replacement.length | ||
); | ); | ||
}, | }, | ||
insertVariable(name) { | insertVariable(name) { | ||
name = | name = String(name || '') | ||
.trim() | |||
.toUpperCase(); | |||
if (!name) { | if (!name) { | ||
| Line 2,994: | Line 2,252: | ||
variable | variable | ||
) { | ) { | ||
type = | type = String(type || '').toLowerCase(); | ||
variable = String(variable || '') | |||
.trim() | |||
.toUpperCase(); | |||
variable = | |||
const selected = | const selected = | ||
| Line 3,038: | Line 2,292: | ||
closing; | closing; | ||
cursorOffset = | cursorOffset = text.length; | ||
} else { | } else { | ||
text = | text = | ||
| Line 3,056: | Line 2,309: | ||
text = | text = | ||
'<!-- @elseif -->'; | '<!-- @elseif -->'; | ||
cursorOffset = | cursorOffset = | ||
text.length; | text.length; | ||
break; | break; | ||
| Line 3,063: | Line 2,318: | ||
text = | text = | ||
'<!-- @else -->'; | '<!-- @else -->'; | ||
cursorOffset = | cursorOffset = | ||
text.length; | text.length; | ||
break; | break; | ||
| Line 3,070: | Line 2,327: | ||
text = | text = | ||
'<!-- @endif -->'; | '<!-- @endif -->'; | ||
cursorOffset = | cursorOffset = | ||
text.length; | text.length; | ||
break; | break; | ||
| Line 3,082: | Line 2,341: | ||
selected.from, | selected.from, | ||
selected.to, | selected.to, | ||
selected.from + | selected.from + cursorOffset | ||
); | ); | ||
}, | }, | ||
| Line 3,098: | Line 2,356: | ||
name => { | name => { | ||
variables.add( | variables.add( | ||
String( | String(name).toUpperCase() | ||
); | |||
); | |||
} | } | ||
); | ); | ||
| Line 3,156: | Line 2,412: | ||
.val(); | .val(); | ||
const populate = | const populate = ( | ||
select, | |||
value | |||
) => { | |||
select.empty(); | |||
variables.forEach( | |||
name => { | |||
$('<option>') | |||
.attr( | |||
'value', | |||
name | |||
) | |||
.text(name) | |||
.appendTo(select); | |||
} | |||
); | |||
variables. | if ( | ||
value && | |||
variables.includes(value) | |||
) { | |||
select.val(value); | |||
} else if ( | |||
variables.length | |||
) { | |||
select.val( | |||
variables[0] | |||
); | ); | ||
} | |||
}; | |||
populate( | populate( | ||
| Line 3,392: | Line 2,647: | ||
name => { | name => { | ||
known.add( | known.add( | ||
String( | String(name).toUpperCase() | ||
); | ); | ||
} | } | ||
| Line 3,422: | Line 2,675: | ||
( | ( | ||
match = | match = | ||
variablePattern.exec( | variablePattern.exec(source) | ||
) !== null | ) !== null | ||
) { | ) { | ||
| Line 3,455: | Line 2,706: | ||
( | ( | ||
match = | match = | ||
conditionPattern.exec( | conditionPattern.exec(source) | ||
) !== null | ) !== null | ||
) { | ) { | ||
| Line 3,540: | Line 2,789: | ||
start, | start, | ||
start + | start + | ||
expressionMatch[0] | expressionMatch[0].length | ||
) | ) | ||
); | ); | ||
| Line 3,614: | Line 2,862: | ||
let settled = false; | let settled = false; | ||
const cleanup = | const cleanup = () => { | ||
mw.hook( | |||
'dev.CCM.ready' | |||
).remove(onReady); | |||
mw.hook( | |||
'dev.CCM.load' | |||
).remove(onLoad); | |||
}; | |||
const onReady = | const onReady = | ||
| Line 3,777: | Line 3,024: | ||
); | ); | ||
}, | }, | ||
renderShell() { | |||
this.destroyEditor(); | this.destroyEditor(); | ||
this.root.empty(); | this.root.empty(); | ||
| Line 5,508: | Line 4,754: | ||
['page', 'Page'], | ['page', 'Page'], | ||
['user', 'User'], | ['user', 'User'], | ||
['date', 'Date'] | ['date', 'Date'], | ||
['file', 'File'] | |||
]; | ]; | ||
| Line 5,676: | Line 4,923: | ||
) { | ) { | ||
this.renderSelectConfiguration( | this.renderSelectConfiguration( | ||
card, | |||
field | |||
); | |||
} | |||
if ( | |||
field.type === 'file' | |||
) { | |||
this.renderFileConfiguration( | |||
card, | card, | ||
field | field | ||
| Line 5,939: | Line 5,195: | ||
'preload-editor-small-textarea' | 'preload-editor-small-textarea' | ||
); | ); | ||
options.on( | options.on( | ||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
const lines = | const lines = | ||
options | options | ||
.val() | .val() | ||
.split('\n') | .split('\n') | ||
.map( | .map( | ||
line => | line => | ||
line.trim() | line.trim() | ||
) | ) | ||
.filter(Boolean); | .filter(Boolean); | ||
if (!lines.length) { | if (!lines.length) { | ||
delete field.options; | 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 { | } else { | ||
field. | delete field.filetext; | ||
} | } | ||
| Line 5,993: | Line 5,359: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'File page text', | ||
filetext, | |||
' | 'Wikitext used for the uploaded File: page.' | ||
) | ) | ||
); | ); | ||
| Line 6,776: | Line 6,142: | ||
let depth = 0; | let depth = 0; | ||
analysis.conditions.forEach( | |||
condition => { | condition => { | ||
if ( | if ( | ||
| Line 6,849: | Line 6,214: | ||
if ( | if ( | ||
mw.config.get('wgPageName') !== | mw.config.get('wgPageName') !== | ||
' | 'Petit_Planet_Wiki:Preload_Editor' | ||
) { | ) { | ||
return; | return; | ||