MediaWiki:Gadget-PreloadEditor.js: Difference between revisions
MediaWiki interface page
More actions
ReisuDesign (talk | contribs) No edit summary |
ReisuDesign (talk | contribs) No edit summary |
||
| Line 3: | Line 3: | ||
const PreloadEditor = { | const PreloadEditor = { | ||
version: '0. | version: '0.8.0', | ||
state: { | state: { | ||
| Line 15: | Line 15: | ||
deletedVariables: [], | deletedVariables: [], | ||
mode: 'idle', | mode: 'idle', | ||
/* | |||
* Keep preload and config dirty independently. | |||
* This is important because "Save Config" must not | |||
* accidentally mark an unsaved preload as saved. | |||
*/ | |||
preloadDirty: false, | |||
configDirty: false, | |||
dirty: false | dirty: false | ||
}, | }, | ||
| Line 25: | Line 34: | ||
status: null, | status: null, | ||
editorTools: null, | editorTools: null, | ||
pageResizeHandler: null, | |||
draftStorageKey: | draftStorageKey: | ||
| Line 34: | Line 44: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Browser-local draft storage | // Browser-local draft storage | ||
// ------------------------------------------------------------ | |||
getDraftStorage() { | getDraftStorage() { | ||
| Line 148: | Line 160: | ||
return { | return { | ||
version: | version: 2, | ||
preloadTitle: | preloadTitle: | ||
| Line 197: | Line 209: | ||
this.state.mode || | this.state.mode || | ||
'edit', | 'edit', | ||
preloadDirty: | |||
!!this.state.preloadDirty, | |||
configDirty: | |||
!!this.state.configDirty, | |||
dirty: true, | dirty: true, | ||
| Line 463: | Line 481: | ||
draft.mode || | draft.mode || | ||
'edit', | 'edit', | ||
preloadDirty: | |||
draft.preloadDirty !== undefined | |||
? !!draft.preloadDirty | |||
: true, | |||
configDirty: | |||
draft.configDirty !== undefined | |||
? !!draft.configDirty | |||
: true, | |||
dirty: true | dirty: true | ||
| Line 485: | Line 513: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Page / viewport mode | |||
// ------------------------------------------------------------ | |||
applyPageMode() { | |||
$('body') | |||
.addClass( | |||
'preload-editor-page' | |||
); | |||
/* | |||
* Hide the normal MediaWiki page heading. | |||
* The editor supplies its own internal headings. | |||
*/ | |||
$( | |||
'#firstHeading, ' + | |||
'.firstHeading' | |||
).hide(); | |||
/* | |||
* Remove the normal heading spacing. | |||
*/ | |||
$( | |||
'#mw-content-text' | |||
).css({ | |||
marginTop: 0 | |||
}); | |||
/* | |||
* Make the editor occupy the remaining viewport | |||
* and keep the outer page from scrolling. | |||
*/ | |||
this.updatePageHeight(); | |||
if (!this.pageResizeHandler) { | |||
this.pageResizeHandler = | |||
() => | |||
this.updatePageHeight(); | |||
window.addEventListener( | |||
'resize', | |||
this.pageResizeHandler | |||
); | |||
} | |||
}, | |||
updatePageHeight() { | |||
if (!this.root || !this.root.length) { | |||
return; | |||
} | |||
const element = | |||
this.root.get(0); | |||
if (!element) { | |||
return; | |||
} | |||
const rect = | |||
element.getBoundingClientRect(); | |||
const height = | |||
Math.max( | |||
300, | |||
window.innerHeight - | |||
rect.top | |||
); | |||
this.root.css({ | |||
height: | |||
height + 'px', | |||
maxHeight: | |||
height + 'px' | |||
}); | |||
}, | |||
// ------------------------------------------------------------ | |||
// API | // API | ||
// ------------------------------------------------------------ | |||
async getPage(title) { | async getPage(title) { | ||
| Line 575: | Line 682: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Preload parsing | // Preload parsing | ||
// ------------------------------------------------------------ | |||
extractIncludeonly(source) { | extractIncludeonly(source) { | ||
| Line 688: | Line 797: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Config | // Config | ||
// ------------------------------------------------------------ | |||
getAutomaticPageField() { | getAutomaticPageField() { | ||
| Line 990: | Line 1,101: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Transformation helpers | // Transformation helpers | ||
// ------------------------------------------------------------ | |||
transformationObject( | transformationObject( | ||
| Line 1,034: | Line 1,147: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// UI helpers | // UI helpers | ||
// ------------------------------------------------------------ | |||
createSection( | createSection( | ||
| Line 1,207: | Line 1,322: | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// In-wiki dialogs | // In-wiki dialogs | ||
// ------------------------------------------------------------ | |||
createConfirmDialog( | createConfirmDialog( | ||
| Line 1,282: | Line 1,399: | ||
.append(overlay); | .append(overlay); | ||
overlay.attr( | |||
'tabindex', | |||
'-1' | |||
); | |||
overlay.trigger('focus'); | |||
overlay.on( | overlay.on( | ||
'keydown.preloadEditor', | 'keydown.preloadEditor', | ||
event => { | event => { | ||
if ( | |||
event.key === 'Escape' | |||
) { | |||
cancel.trigger('click'); | |||
} | |||
if ( | |||
event.key === 'Enter' | |||
) { | |||
confirm.trigger('click'); | |||
} | |||
} | } | ||
); | ); | ||
overlay.on( | overlay.on( | ||
'click.preloadEditor', | 'click.preloadEditor', | ||
| Line 1,423: | Line 1,539: | ||
}, | }, | ||
createConfigReviewDialog() { | |||
return new Promise( | |||
resolve => { | |||
const overlay = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt-overlay preload-editor-config-review-overlay' | |||
); | |||
const box = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-prompt preload-editor-config-review' | |||
); | |||
$('<h2>') | |||
.text( | |||
'Generated configuration' | |||
) | |||
.appendTo(box); | |||
$('<p>') | |||
.text( | |||
'This is the JSON that will be uploaded to the preload\'s config.json page.' | |||
) | |||
.appendTo(box); | |||
const preview = | |||
$('<pre>') | |||
.addClass( | |||
'preload-editor-config-review-preview' | |||
) | |||
.text( | |||
this.getConfigJSON() | |||
); | |||
box.append(preview); | |||
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'); | |||
} | |||
} | |||
); | |||
} | } | ||
); | |||
}, | }, | ||
// ------------------------------------------------------------ | |||
// CodeMirror | |||
// ------------------------------------------------------------ | |||
destroyEditor() { | |||
if (!this.editor) { | |||
this.editorView = null; | |||
this.editorTools = null; | |||
return; | |||
} | |||
const { | |||
view, | |||
inputHandler | |||
} = this.editor; | |||
if ( | 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) { | if ( | ||
this.editorView. | this.editorView && | ||
this.editorView.state | |||
) { | |||
return this.editorView.state.doc.toString(); | |||
} | } | ||
return ( | |||
this.state.preloadContent || '' | |||
); | |||
}, | }, | ||
setEditorContent(content) { | |||
content = | |||
String(content || ''); | |||
if (!this.editorView) { | if (!this.editorView) { | ||
this.state.preloadContent = | |||
content; | |||
return; | return; | ||
} | } | ||
const | const current = | ||
this.editorView; | this.editorView.state.doc.toString(); | ||
if (current === content) { | |||
return; | |||
} | |||
this.editorView.dispatch({ | |||
changes: { | changes: { | ||
from, | from: 0, | ||
to | to: | ||
this.editorView.state.doc.length, | |||
insert: content | |||
} | } | ||
}); | }); | ||
}, | }, | ||
getEditorSelection() { | |||
if ( | if ( | ||
! | !this.editorView || | ||
!this.editorView.state | |||
) { | ) { | ||
return null; | |||
return; | |||
} | } | ||
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(); | |||
} | |||
}, | |||
if (! | insertEditorText( | ||
text, | |||
selectionFrom, | |||
selectionTo, | |||
cursorPosition | |||
) { | |||
if (!this.editorView) { | |||
return; | return; | ||
} | } | ||
const | const view = | ||
this. | this.editorView; | ||
const selection = | |||
view.state.selection.main; | |||
const from = | |||
selectionFrom === undefined | |||
? selection.from | |||
: selectionFrom; | |||
const to = | |||
selectionTo === undefined | |||
? selection.to | |||
: selectionTo; | |||
. | |||
const cursor = | |||
cursorPosition === undefined | |||
. | ? from + text.length | ||
: cursorPosition; | |||
view.dispatch({ | |||
changes: { | |||
from, | |||
to, | |||
insert: text | |||
}, | |||
selection: { | |||
anchor: cursor | |||
} | } | ||
}); | |||
view.focus(); | |||
}, | |||
// ------------------------------------------------------------ | |||
// Editor tools | |||
// ------------------------------------------------------------ | |||
wrapSelectionWithVariable() { | |||
const selected = | |||
this.getEditorSelection(); | |||
if ( | |||
!selected || | |||
selected.from === selected.to | |||
) { | |||
this.focusEditor(); | |||
return; | |||
} | |||
const replacement = | |||
'%%' + | |||
selected.text + | |||
'%%'; | |||
this.insertEditorText( | |||
replacement, | |||
this.insertEditorText( | |||
selected.from, | selected.from, | ||
selected.to, | selected.to, | ||
selected.from + | selected.from + | ||
replacement.length | |||
); | ); | ||
}, | }, | ||
insertVariable(name) { | |||
name = | |||
String(name || '') | |||
.trim() | |||
.toUpperCase(); | |||
if ( | 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) { | |||
if (! | |||
return; | return; | ||
} | } | ||
let text; | |||
let cursorOffset; | |||
switch (type) { | |||
case 'if': { | |||
if (!variable) { | |||
return; | |||
} | |||
const opening = | |||
'<!-- @if ' + | |||
variable + | |||
' -->'; | |||
const closing = | |||
'<!-- @endif -->'; | |||
if ( | if ( | ||
selected.from !== | |||
selected.to | |||
) { | ) { | ||
text = | |||
opening + | |||
'\n' + | |||
selected.text + | |||
'\n' + | |||
closing; | |||
cursorOffset = | |||
text.length; | |||
} else { | |||
text = | |||
opening + | |||
'\n\n' + | |||
closing; | |||
cursorOffset = | |||
opening.length + 1; | |||
} | |||
break; | |||
} | |||
case 'elseif': | |||
text = | |||
. | '<!-- @elseif -->'; | ||
cursorOffset = | |||
text.length; | |||
break; | |||
case 'else': | |||
text = | |||
'<!-- @else -->'; | |||
cursorOffset = | |||
text.length; | |||
break; | |||
case 'endif': | |||
text = | |||
'<!-- @endif -->'; | |||
cursorOffset = | |||
text.length; | |||
break; | |||
default: | |||
return; | |||
} | |||
this.insertEditorText( | |||
text, | |||
selected.from, | |||
selected.to, | |||
selected.from + | |||
cursorOffset | |||
); | ); | ||
}, | |||
const | 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>') | |||
const | |||
$('< | |||
.addClass( | .addClass( | ||
'preload-editor-tool- | '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.getToolVariables(); | |||
const | const current = | ||
this. | this.editorTools | ||
.variableSelect | |||
.val(); | |||
const currentCondition = | |||
this.editorTools | |||
.conditionVariableSelect | |||
.val(); | |||
const populate = | |||
( | |||
select, | |||
value | |||
) => { | |||
select.empty(); | |||
variables.forEach( | |||
name => { | |||
$('<option>') | |||
.attr( | |||
'value', | |||
name | |||
) | |||
.text(name) | |||
.appendTo(select); | |||
} | |||
); | |||
if ( | |||
value && | |||
variables.includes(value) | |||
) { | |||
select.val(value); | |||
} else if ( | |||
variables.length | |||
) { | |||
select.val( | |||
variables[0] | |||
); | |||
} | |||
}; | |||
populate( | |||
this.editorTools.variableSelect, | |||
current | |||
); | ); | ||
populate( | |||
this.editorTools.conditionVariableSelect, | |||
currentCondition | |||
); | ); | ||
}, | |||
this.editorTools | updateConditionVariableVisibility() { | ||
if (!this.editorTools) { | |||
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( | |||
const | this.createButton( | ||
'Wrap selection with %%', | |||
'preload-editor-tool-button', | |||
() => | |||
this.wrapSelectionWithVariable() | |||
) | |||
); | |||
const variableGroup = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-tool-group preload-editor-tool-group-variable' | |||
); | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-tool-label' | |||
) | |||
.text( | |||
'Insert existing variable' | |||
) | |||
.appendTo(variableGroup); | |||
const variableSelect = | |||
this.createToolSelect( | |||
[], | |||
'preload-editor-variable-select' | |||
); | ); | ||
variableGroup.append( | |||
variableSelect, | |||
this.createButton( | |||
'Insert', | |||
'preload-editor-tool-button', | |||
() => | |||
this.insertVariable( | |||
variableSelect.val() | |||
) | |||
) | |||
); | |||
const | const conditionGroup = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-tool-group preload-editor-tool-group-condition' | |||
); | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-tool-label' | |||
) | |||
.text('Insert condition') | |||
.appendTo(conditionGroup); | |||
const conditionTypeSelect = | |||
this.createToolSelect( | |||
[ | |||
( | { | ||
value: 'if', | |||
label: '@if' | |||
}, | |||
{ | |||
value: 'elseif', | |||
label: '@elseif' | |||
}, | |||
{ | |||
value: 'else', | |||
label: '@else' | |||
}, | |||
{ | |||
value: 'endif', | |||
label: '@endif' | |||
} | |||
], | |||
'preload-editor-condition-type-select' | |||
); | |||
const conditionVariableSelect = | |||
this.createToolSelect( | |||
[], | |||
'preload-editor-condition-variable-select' | |||
); | ); | ||
const | 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( | |||
'preload-editor- | Decoration.mark({ | ||
); | class: | ||
'preload-editor-syntax-condition-variable' | |||
}).range( | |||
start, | |||
start + | |||
expressionMatch[0] | |||
.length | |||
) | |||
); | |||
} | |||
} | |||
return Decoration.set( | |||
decorations, | |||
true | |||
); | |||
}, | |||
async installSyntaxHighlighting(view) { | |||
await mw.loader.using( | |||
'ext.CodeMirror.v6.lib' | |||
); | |||
const require = | |||
mw.loader.require; | |||
const { | |||
StateField, | |||
StateEffect, | |||
EditorView, | |||
Decoration | |||
} = require( | |||
'ext.CodeMirror.v6.lib' | |||
); | |||
const syntaxField = | |||
StateField.define({ | |||
create: state => | |||
this.createSyntaxDecorations( | |||
state, | |||
Decoration | |||
), | |||
update: ( | |||
decorations, | |||
transaction | |||
) | ) => | ||
transaction.docChanged | |||
? this.createSyntaxDecorations( | |||
transaction.state, | |||
Decoration | |||
) | |||
: decorations, | |||
provide: field => | |||
EditorView.decorations.from( | |||
field | |||
) | |||
}); | |||
view.dispatch({ | |||
effects: | |||
StateEffect.appendConfig.of( | |||
syntaxField | |||
) | |||
}); | |||
this.editor.syntaxField = | |||
syntaxField; | |||
}, | |||
async mountCodeMirror(textarea) { | |||
return new Promise( | |||
( | |||
resolve, | |||
reject | |||
) => { | |||
let settled = false; | |||
const cleanup = | |||
() => { | |||
mw.hook( | |||
'dev.CCM.ready' | |||
).remove(onReady); | |||
mw.hook( | |||
'dev.CCM.load' | |||
).remove(onLoad); | |||
}; | }; | ||
const onReady = | |||
async cm => { | |||
if ( | |||
settled || | |||
!cm || | |||
cm.textarea !== textarea || | |||
!cm.view | |||
) { | |||
return; | |||
} | |||
settled = true; | |||
cleanup(); | |||
const view = | |||
cm.view; | |||
const inputHandler = | |||
() => { | |||
this.state | |||
.preloadContent = | |||
view.state.doc.toString(); | |||
this.state.preloadDirty = | |||
true; | |||
this.updateDirtyState(); | |||
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>') | ||
.attr( | |||
'id', | |||
'preload-editor' | |||
) | |||
.addClass( | .addClass( | ||
'preload-editor | 'preload-editor' | ||
); | ); | ||
root.append(this.root); | |||
this.applyPageMode(); | |||
this.renderShell(); | |||
this.restoreDraft(); | |||
. | window.addEventListener( | ||
'beforeunload', | |||
() => { | |||
if (this.state.dirty) { | |||
this.saveDraft(); | |||
} | |||
} | } | ||
); | ); | ||
}, | }, | ||
renderShell() { | |||
this.destroyEditor(); | |||
this.root.empty(); | |||
this. | const loader = | ||
this.createSection('Preload'); | |||
const titleInput = | |||
this.textInput( | |||
' | this.state.preloadTitle, | ||
'Template:Example/Preload' | |||
).attr( | |||
'id', | |||
'preload-editor-title-input' | |||
); | ); | ||
this. | titleInput.on( | ||
'input.preloadEditor', | |||
() => { | |||
this.state.preloadTitle = | |||
String( | |||
titleInput.val() || '' | |||
).trim(); | |||
if ( | |||
this.state.dirty && | |||
this.state.mode === 'new' | |||
) { | |||
this.saveDraft(); | |||
} | |||
} | |||
); | |||
const loadButton = | |||
this.createButton( | |||
' | 'Load', | ||
'preload-editor-button-primary', | |||
() => | |||
this.loadExisting( | |||
titleInput.val() | |||
) | |||
); | ); | ||
const newButton = | |||
this.createButton( | |||
'New', | |||
'', | |||
() => this.startNew() | |||
); | |||
this. | const sampleButton = | ||
this.createButton( | |||
'Load Sample Page', | |||
'', | |||
() => this.loadSamplePage() | |||
); | |||
loader.append( | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-toolbar' | |||
) | |||
.append( | |||
titleInput, | |||
loadButton, | |||
newButton, | |||
sampleButton | |||
) | |||
); | |||
this.root.append(loader); | |||
this.status = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-status' | |||
); | ); | ||
this.root.append(this.status); | |||
' | this.workspace = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-workspace' | |||
); | ); | ||
this.root.append(this.workspace); | |||
this.renderWelcome(); | |||
this.updatePageHeight(); | |||
}, | |||
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 | |||
) { | ) { | ||
this. | if (!this.status) { | ||
return; | |||
} | |||
this.status.removeClass( | |||
'is-error', | |||
'is-success', | |||
'is-warning', | |||
'is-loading' | |||
); | |||
if (type) { | |||
this. | this.status.addClass( | ||
' | 'is-' + type | ||
); | ); | ||
} | |||
this.status.text( | |||
message || '' | |||
); | |||
}, | |||
updateDirtyState() { | |||
this.state.dirty = | |||
. | !!( | ||
. | this.state.preloadDirty || | ||
this.state.configDirty | |||
); | |||
}, | |||
markPreloadDirty() { | |||
this.state.preloadDirty = true; | |||
this.updateDirtyState(); | |||
this.saveDraft(); | |||
}, | |||
markConfigDirty() { | |||
this.state.configDirty = true; | |||
this.updateDirtyState(); | |||
this.saveDraft(); | |||
}, | |||
markDirty() { | |||
/* | |||
* Existing callers generally modify config fields, | |||
* so markDirty() means the configuration changed. | |||
*/ | |||
this.markConfigDirty(); | |||
}, | |||
// ------------------------------------------------------------ | |||
// Load / create | |||
// ------------------------------------------------------------ | |||
async loadExisting(title) { | |||
async | |||
title = | title = | ||
String(title || '').trim(); | String(title || '').trim(); | ||
if (!title) { | |||
mw.notify( | |||
'Enter a preload page title first.', | |||
{ | |||
type: 'warn' | |||
} | |||
); | |||
return; | |||
} | |||
} | |||
this.setStatus( | this.setStatus( | ||
' | 'Loading preload...', | ||
' | 'loading' | ||
); | ); | ||
try { | |||
try { | |||
const page = | const page = | ||
await this.getPage( | await this.getPage(title); | ||
if (!page) { | if (!page) { | ||
const suggestions = | |||
await this.findSimilarPreloads( | |||
title | |||
); | |||
this.renderMissingPage( | |||
title, | |||
suggestions | |||
); | ); | ||
this.setStatus( | |||
this. | 'Preload not found.', | ||
'warning' | |||
); | ); | ||
return; | |||
} | |||
await this.loadDefinition( | |||
page.title, | |||
page.content | |||
); | |||
this.setStatus( | this.setStatus( | ||
' | 'Preload loaded.', | ||
'success' | 'success' | ||
); | ); | ||
} catch (error) { | } catch (error) { | ||
console.error( | console.error( | ||
'Preload Editor | 'Preload Editor:', | ||
error | error | ||
); | ); | ||
| Line 2,823: | Line 2,883: | ||
this.setStatus( | this.setStatus( | ||
error.message || | error.message || | ||
'Could not load | 'Could not load preload.', | ||
'error' | 'error' | ||
); | ); | ||
| Line 2,829: | Line 2,889: | ||
}, | }, | ||
async findSimilarPreloads(title) { | |||
let | let query = | ||
title | |||
.replace( | .replace( | ||
/ | /\/Preload$/i, | ||
'' | '' | ||
) | ) | ||
.trim(); | .trim(); | ||
if (!query) { | |||
query = title; | |||
} | |||
' | try { | ||
const results = | |||
await this.searchPages(query); | |||
return results.filter( | |||
result => | |||
/\/Preload$/i.test( | |||
result.title | |||
) | |||
); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor search:', | |||
error | |||
); | |||
return []; | |||
} | |||
}, | }, | ||
renderMissingPage( | |||
title, | title, | ||
suggestions | |||
) { | ) { | ||
this.workspace.empty(); | |||
const section = | |||
this. | this.createSection( | ||
'Preload not found' | |||
); | |||
$('<p>') | |||
.text( | |||
'No preload exists at "' + | |||
'' | title + | ||
) | '".' | ||
) | |||
.appendTo(section); | |||
if (suggestions.length) { | |||
$('<h3>') | |||
.text('Similar preloads') | |||
.appendTo(section); | |||
const list = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-suggestions' | |||
); | ); | ||
suggestions.forEach( | |||
result => { | |||
list.append( | |||
this.createButton( | |||
result.title, | |||
'preload-editor-suggestion', | |||
() => | |||
this.loadExisting( | |||
result.title | |||
) | |||
) | |||
); | |||
} | |||
); | |||
section.append(list); | |||
} | |||
section.append( | |||
this. | 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 = { | this.state = { | ||
preloadTitle: title, | preloadTitle: title, | ||
preloadSource: | preloadSource: '', | ||
preloadContent: | preloadContent: | ||
this.defaults.preload, | |||
config: | |||
this.emptyConfig(), | |||
conditions: | variables: ['PAGE'], | ||
conditions: [], | |||
selectedVariable: 'PAGE', | |||
deletedVariables: [], | |||
mode: 'new', | |||
preloadDirty: true, | |||
configDirty: true, | |||
dirty: true | |||
}; | |||
this.saveDraft(); | |||
this.renderEditor(); | |||
this. | this.setStatus( | ||
'Creating a new preload.', | |||
'success' | |||
); | |||
}, | }, | ||
async loadSamplePage() { | |||
const result = | |||
await this.createPromptDialog( | |||
'Load Sample Page', | |||
'Enter the title of a normal wiki page to use as the starting point.' | |||
); | |||
if (!result) { | |||
return; | |||
} | |||
this.setStatus( | |||
this. | '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 = { | |||
this. | preloadTitle: '', | ||
' | preloadSource: '', | ||
' | preloadContent: sample, | ||
config: | |||
this.emptyConfig(), | |||
variables: ['PAGE'], | |||
conditions: [], | |||
selectedVariable: 'PAGE', | |||
' | deletedVariables: [], | ||
mode: 'new', | |||
preloadDirty: true, | |||
configDirty: true, | |||
dirty: true | |||
}; | |||
this.reanalyze(); | |||
this. | 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 | 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 | const configTitle = | ||
title.replace( | |||
/\/+$/, | |||
'' | |||
) + | |||
'/config.json'; | |||
const configPage = | |||
await this.getPage(configTitle); | |||
if (configPage) { | |||
try { | |||
' | config = | ||
JSON.parse( | |||
configPage.content | |||
); | |||
} catch (error) { | |||
throw new Error( | |||
'The existing config.json contains invalid JSON: ' + | |||
error.message | |||
); | |||
} | |||
} | |||
const | const analysis = | ||
this.analyzePreload(content); | |||
this.state.deletedVariables = []; | |||
config = | |||
this. | 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', | |||
preloadDirty: false, | |||
configDirty: false, | |||
dirty: false | |||
}; | |||
this. | this.clearDraft(title); | ||
this.renderEditor(); | |||
}, | |||
// ------------------------------------------------------------ | |||
// Editor | |||
// ------------------------------------------------------------ | |||
this. | renderEditor() { | ||
this.destroyEditor(); | |||
); | this.workspace.empty(); | ||
const layout = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-layout' | |||
); | |||
const left = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-left' | |||
' | |||
); | ); | ||
const right = | |||
$('<div>') | |||
.addClass( | |||
' | 'preload-editor-right' | ||
); | ); | ||
const editorSection = | |||
const | this.createSection( | ||
this. | 'Preload', | ||
'preload-editor-main-section' | |||
); | |||
const meta = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-meta' | |||
); | |||
this.state. | $('<strong>') | ||
.text( | |||
this.state.preloadTitle || | |||
'New preload' | |||
) | |||
.appendTo(meta); | |||
this. | if ( | ||
this.state.mode === 'new' | |||
) { | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge' | |||
) | |||
.text('NEW') | |||
.appendTo(meta); | |||
} | |||
editorSection.append(meta); | |||
editorSection.append( | |||
this.renderEditorTools() | |||
); | ); | ||
const textarea = | |||
const | this.textarea( | ||
this. | this.state.preloadContent | ||
) | |||
.attr( | |||
'id', | |||
'preload-editor-content' | |||
) | |||
.addClass( | |||
'preload-editor-content' | |||
); | |||
const | const editorMount = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-codemirror-mount' | |||
) | |||
.append(textarea); | |||
editorSection.append(editorMount); | |||
left.append(editorSection); | |||
// Analysis | |||
const analysisSection = | |||
this.createSection('Analysis'); | |||
const analysisContainer = | |||
analysis | $('<div>') | ||
.addClass( | |||
'preload-editor-analysis' | |||
); | |||
analysisSection.append( | |||
analysisContainer | |||
); | |||
// Variables | |||
const | const variablesSection = | ||
this.createSection('Variables'); | |||
const detectVariablesButton = | |||
this.createButton( | |||
'Detect variables', | |||
'preload-editor-button-primary preload-editor-detect-variables', | |||
() => | |||
this.detectVariables() | |||
); | |||
const detectHelp = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-help preload-editor-detect-variables-help' | |||
) | |||
.text( | |||
'Refresh the variable list after editing the preload.' | |||
); | |||
variablesSection.append( | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-detect' | |||
) | |||
.append( | |||
detectVariablesButton, | |||
detectHelp | |||
) | ) | ||
); | |||
const | const variableContainer = | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-variables' | |||
); | |||
variablesSection.append( | |||
variableContainer | |||
); | |||
// Configuration | |||
const | const configurationSection = | ||
this.createSection( | |||
'Variable configuration' | |||
); | |||
) | |||
const | const configurationContainer = | ||
$('<div>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-variable-configuration' | ||
); | ); | ||
configurationSection.append( | |||
configurationContainer | |||
); | |||
/* | |||
* -------------------------------------------------------- | |||
* Generated configuration | |||
* -------------------------------------------------------- | |||
* | |||
* The giant JSON preview is gone from the main workspace. | |||
* We now have one compact button which opens a review | |||
* dialog. | |||
*/ | |||
const configSection = | |||
this.createSection( | |||
'Configuration' | |||
); | |||
const configReview = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-config-review-trigger' | |||
); | |||
$('<p>') | |||
.addClass( | |||
'preload-editor-analysis-note' | |||
) | |||
.text( | |||
) | 'Review the generated config.json before saving it.' | ||
) | |||
.appendTo(configReview); | |||
configReview.append( | |||
this.createButton( | |||
'Review generated JSON', | |||
'preload-editor-button-primary preload-editor-review-config', | |||
() => | |||
this.createConfigReviewDialog() | |||
) | ) | ||
); | ); | ||
configSection.append( | |||
configReview | |||
); | |||
// Save actions | |||
const actions = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-actions' | |||
); | |||
actions.append( | |||
this. | this.createButton( | ||
'Save Preload', | |||
'preload-editor-button-primary preload-editor-save-preload', | |||
) | () => | ||
this.savePreloadOnly() | |||
), | |||
this.createButton( | |||
'Save Config', | |||
'preload-editor-button-primary preload-editor-save-config', | |||
() => | |||
this.saveConfigOnly() | |||
) | ), | ||
this.createButton( | |||
'Save', | |||
'preload-editor-button-primary preload-editor-save-all', | |||
() => | |||
this.saveAll() | |||
) | |||
); | ); | ||
right.append( | |||
analysisSection, | |||
variablesSection, | |||
configurationSection, | |||
configSection, | |||
actions | |||
); | |||
layout.append( | |||
left, | |||
right | |||
); | |||
this.workspace.append(layout); | |||
this.reanalyze(); | |||
this.renderVariables( | |||
variableContainer | |||
); | |||
this.renderAnalysis( | |||
analysisContainer | |||
); | |||
) | |||
this.renderVariableConfiguration( | |||
configurationContainer | |||
); | |||
this.updateEditorTools(); | |||
this.mountCodeMirror( | |||
textarea.get(0) | |||
).catch( | |||
) { | error => { | ||
console.error( | |||
'Preload Editor CodeMirror:', | |||
error | |||
); | |||
this.setStatus( | |||
error.message || | |||
'Could not initialize CodeMirror.', | |||
'error' | |||
); | |||
} | |||
); | |||
this.updatePageHeight(); | |||
}, | |||
const | detectVariables() { | ||
const source = | |||
this.getEditorContent(); | |||
this.state.preloadContent = | |||
source; | |||
this.markPreloadDirty(); | |||
this.reanalyze(); | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.updateEditorTools(); | |||
this.saveDraft(); | |||
this.setStatus( | |||
'Variables detected from the current preload.', | |||
'success' | |||
); | ); | ||
}, | |||
const | reanalyze() { | ||
const source = | |||
this.getEditorContent(); | |||
const content = | |||
this.extractIncludeonly(source); | |||
const analysis = | |||
this.analyzePreload(content); | |||
this.state.preloadContent = | |||
source; | |||
this.state.variables = | |||
analysis.variables; | |||
this.state.conditions = | |||
analysis.conditions; | |||
const beforeConfig = | |||
this.state.config | |||
? JSON.stringify( | |||
this.state.config | |||
) | |||
: ''; | |||
this.state.config = | |||
this.prepareConfigForVariables( | |||
this.state.config || | |||
this.emptyConfig(), | |||
analysis | |||
); | |||
const afterConfig = | |||
JSON.stringify( | |||
this.state.config | |||
); | |||
/* | |||
* If analysis automatically created a new field, | |||
* that is a real config change and should be saved. | |||
*/ | |||
if ( | |||
beforeConfig && | |||
beforeConfig !== afterConfig | |||
) { | |||
this.state.configDirty = true; | |||
this.updateDirtyState(); | |||
} | |||
this.analysis = | |||
analysis; | |||
const available = | |||
['PAGE'].concat( | |||
analysis.variables.filter( | |||
name => | |||
name !== 'PAGE' | |||
) | |||
' | |||
); | ); | ||
if ( | if ( | ||
!available.includes( | |||
this.state.selectedVariable | |||
) | |||
) { | ) { | ||
this. | this.state.selectedVariable = | ||
available[0] || | |||
'PAGE'; | |||
} | } | ||
}, | }, | ||
// ------------------------------------------------------------ | |||
// Analysis | |||
// ------------------------------------------------------------ | |||
renderAnalysis(container) { | |||
container.empty(); | |||
const | const analysis = | ||
this.analysis || | |||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
this.getEditorContent() | |||
) | ) | ||
); | ); | ||
const | const variableCount = | ||
analysis.variables.length; | |||
const conditionCount = | |||
analysis.conditions.length; | |||
const configuredCount = | |||
Object.keys( | |||
this.state.config && | |||
this.state.config.fields | |||
? this.state.config.fields | |||
: {} | |||
).length; | |||
const summary = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis-summary' | |||
); | |||
const createMetric = | |||
( | |||
value, | |||
label | |||
) => | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis-metric' | |||
) | |||
.append( | |||
$('<strong>') | |||
.addClass( | |||
'preload-editor-analysis-value' | |||
) | |||
.text(value), | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-analysis-label' | |||
) | |||
.text(label) | |||
); | |||
summary.append( | |||
createMetric( | |||
variableCount, | |||
variableCount === 1 | |||
? 'variable' | |||
: 'variables' | |||
) | ), | ||
createMetric( | |||
conditionCount, | |||
conditionCount === 1 | |||
? 'condition marker' | |||
: 'condition markers' | |||
), | |||
createMetric( | |||
configuredCount, | |||
configuredCount === 1 | |||
? 'configured field' | |||
: 'configured fields' | |||
) | |||
); | |||
container.append(summary); | |||
if ( | |||
analysis.conditionVariables.length | |||
) { | |||
container.append( | container.append( | ||
$('<div>') | |||
.addClass( | |||
'preload-editor-analysis-note' | |||
) | |||
.text( | |||
'Condition variables: ' + | |||
analysis.conditionVariables.join(', ') | |||
) | |||
); | ); | ||
} | |||
const validation = | |||
this.validateConfig( | |||
this.state.config, | |||
analysis | |||
); | |||
if ( | if (validation.errors.length) { | ||
const | const errors = | ||
$('< | $('<ul>') | ||
.addClass( | .addClass( | ||
'preload-editor- | '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( | container.append(warnings); | ||
} | } | ||
container.append( | if ( | ||
!validation.errors.length && | |||
!validation.warnings.length | |||
) { | |||
container.append( | |||
) | $('<div>') | ||
.addClass( | |||
'preload-editor-validation-success' | |||
) | |||
.text( | |||
'Preload and configuration are in sync.' | |||
) | |||
); | |||
} | |||
}, | }, | ||
renderAnalysisBySelector() { | |||
const | const container = | ||
this.root && | |||
this.root.find( | |||
'.preload-editor-analysis' | |||
); | |||
if ( | |||
container && | |||
container.length | |||
) { | |||
this.renderAnalysis(container); | |||
} | |||
}, | |||
// ------------------------------------------------------------ | |||
// Variables | |||
// ------------------------------------------------------------ | |||
renderVariables(container) { | |||
container.empty(); | |||
const analysis = | |||
this.analysis || | |||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
this.getEditorContent() | |||
) | |||
); | |||
const variables = | |||
analysis.variables.slice(); | |||
const | const ordered = | ||
['PAGE']; | |||
variables.forEach( | |||
name => { | |||
if ( | |||
name !== 'PAGE' && | |||
!ordered.includes(name) | |||
) { | |||
' | ordered.push(name); | ||
) | } | ||
} | |||
); | |||
const | const configuredFields = | ||
this. | this.state.config && | ||
this.state.config.fields | |||
? Object.keys( | |||
this.state.config.fields | |||
) | |||
: []; | |||
configuredFields.forEach( | |||
name => { | |||
const upper = | |||
name.toUpperCase(); | |||
if ( | |||
upper !== 'PAGE' && | |||
!ordered.includes(upper) | |||
) { | |||
ordered.push(upper); | |||
} | |||
} | |||
); | |||
const | const deleted = | ||
Array.isArray( | |||
this.state.deletedVariables | |||
) | ) | ||
? this.state.deletedVariables | |||
: []; | |||
deleted.forEach( | |||
name => { | |||
const upper = | |||
String(name).toUpperCase(); | |||
if ( | |||
upper !== 'PAGE' && | |||
!ordered.includes(upper) | |||
) | ) { | ||
ordered.push(upper); | |||
} | |||
} | |||
); | |||
const list = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-list' | |||
); | |||
ordered.forEach( | |||
name => { | |||
const isSelected = | |||
name === | |||
this.state.selectedVariable; | |||
const isUsed = | |||
analysis.variables.includes( | |||
name | |||
); | |||
const isConfigured = | |||
!!( | |||
this.state.config && | |||
this.state.config.fields && | |||
this.state.config.fields[name] | |||
); | |||
const item = | |||
$('<button>') | |||
.attr({ | |||
type: 'button', | |||
'data-variable': | |||
name | |||
}) | |||
.addClass( | |||
'preload-editor-variable-item' | |||
) | |||
.toggleClass( | |||
'is-selected', | |||
isSelected | |||
) | |||
.on( | |||
'click.preloadEditor', | |||
() => | |||
this.selectVariable( | |||
name | |||
) | |||
); | |||
$('<span>') | $('<span>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-variable-name' | ||
) | ) | ||
.text( | .text(name) | ||
.appendTo(item); | |||
if (name === 'PAGE') { | |||
item.append( | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge' | |||
) | |||
.text('AUTOMATIC') | |||
); | |||
} else if (!isConfigured) { | |||
item.append( | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge is-warning' | |||
) | |||
.text( | |||
isUsed | |||
? 'NOT CONFIGURED' | |||
: 'DELETED' | |||
) | |||
); | |||
} else if (!isUsed) { | |||
item.append( | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge is-warning' | |||
) | |||
.text('UNUSED') | |||
); | |||
} | |||
list.append(item); | |||
} | } | ||
); | ); | ||
container.append(list); | |||
if (!ordered.length) { | |||
container.append( | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-empty' | |||
) | |||
.text( | |||
'No variables found in the preload.' | |||
) | |||
); | ); | ||
} | |||
}, | |||
renderVariablesBySelector() { | |||
const container = | |||
( | this.root && | ||
this.root.find( | |||
'.preload-editor-variables' | |||
); | |||
if ( | |||
container && | |||
container.length | |||
) { | |||
this.renderVariables(container); | |||
} | |||
}, | |||
selectVariable(name) { | |||
name = | |||
String(name || '') | |||
.trim() | |||
.toUpperCase(); | |||
if (!name) { | |||
return; | |||
} | |||
const | const available = | ||
['PAGE'].concat( | |||
( | |||
this.analysis && | |||
this.analysis.variables | |||
? this.analysis.variables | |||
: [] | |||
) | ).filter( | ||
variable => | |||
variable !== 'PAGE' | |||
) | |||
); | ); | ||
const configuredUnused = | |||
this.state.config && | |||
this.state.config.fields | |||
? Object.keys( | |||
this.state.config.fields | |||
) | |||
.map( | |||
variable => | |||
variable.toUpperCase() | |||
) | |||
: []; | |||
configuredUnused.forEach( | |||
variable => { | |||
if ( | |||
!available.includes(variable) && | |||
variable !== 'PAGE' | |||
) { | |||
available.push(variable); | |||
} | |||
} | } | ||
); | ); | ||
if (!available.includes(name)) { | |||
return; | |||
} | |||
this.state.selectedVariable = | |||
name; | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
}, | |||
renderVariableConfigurationBySelector() { | |||
const container = | |||
this.root && | |||
this.root.find( | |||
'.preload-editor-variable-configuration' | |||
); | |||
if ( | |||
this. | container && | ||
container.length | |||
) { | |||
this.renderVariableConfiguration( | |||
); | container | ||
); | |||
} | |||
}, | |||
renderVariableConfiguration(container) { | |||
container.empty(); | |||
const | const analysis = | ||
this. | this.analysis || | ||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
this.getEditorContent() | |||
) | |||
); | ); | ||
const name = | |||
' | this.state.selectedVariable || | ||
'PAGE'; | |||
const fields = | |||
this.state.config && | |||
this.state.config.fields | |||
? this.state.config.fields | |||
: {}; | |||
} | |||
const field = | |||
fields[name]; | |||
if (name === 'PAGE') { | |||
container.append( | |||
this.createAutomaticPageCard( | |||
field || | |||
this.getAutomaticPageField() | |||
) | |||
); | ); | ||
return; | |||
} | |||
if (!field) { | |||
const missing = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable preload-editor-variable-missing' | |||
); | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-header' | |||
) | |||
.append( | |||
$('<h3>') | |||
.text(name), | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge is-warning' | |||
) | |||
' | .text( | ||
'NOT CONFIGURED' | |||
) | |||
) | |||
.appendTo(missing); | |||
$('<p>') | |||
.addClass( | |||
'preload-editor-analysis-note' | |||
) | |||
.text( | |||
'This variable is currently not present in config.json.' | |||
) | |||
.appendTo(missing); | |||
container.append(missing); | |||
return; | |||
} | |||
container.append( | |||
this.createVariableCard( | |||
name, | |||
field, | |||
analysis | |||
) | ) | ||
); | ); | ||
}, | |||
createAutomaticPageCard(field) { | |||
const card = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable preload-editor-variable-automatic' | |||
) | |||
).attr( | .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' | |||
) | |||
this. | .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' | |||
const | ) | ||
this. | .prop( | ||
' | 'disabled', | ||
'preload-editor- | true | ||
); | |||
const type = | |||
this.textInput( | |||
field.type || 'page' | |||
).prop( | |||
'disabled', | |||
true | |||
); | |||
const required = | |||
this.checkbox( | |||
field.required !== false | |||
).prop( | |||
'disabled', | |||
true | |||
); | |||
const namespace = | |||
this.textInput( | |||
field.namespace || 'Main' | |||
).prop( | |||
'disabled', | |||
true | |||
); | ); | ||
const allowNew = | |||
this.checkbox( | |||
field['allow-new'] === true | |||
).prop( | |||
'disabled', | |||
true | |||
); | |||
card.append( | |||
this.fieldRow( | |||
'Label', | |||
label | |||
), | |||
this.fieldRow( | |||
'Description', | |||
description | |||
), | |||
this.fieldRow( | |||
'Type', | |||
type | |||
), | |||
this.fieldRow( | |||
'Required', | |||
required | |||
), | |||
this.fieldRow( | |||
'Namespace', | |||
namespace | |||
), | |||
this.fieldRow( | |||
'Allow new page', | |||
allowNew | |||
) | ) | ||
) { | ); | ||
return card; | |||
}, | |||
createVariableCard( | |||
name, | |||
field, | |||
analysis | |||
) { | |||
const card = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable' | |||
) | |||
.attr( | |||
'data-variable', | |||
name | |||
); | |||
const header = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-variable-header' | |||
); | |||
header.append( | |||
$('<h3>').text(name) | |||
); | |||
if ( | if ( | ||
! | !analysis.variables.includes(name) | ||
) { | ) { | ||
header.append( | |||
$('<span>') | |||
.addClass( | |||
'preload-editor-badge is-warning' | |||
) | |||
.text('UNUSED') | |||
); | ); | ||
} | } | ||
card.append(header); | |||
const label = | |||
const | |||
this.textInput( | this.textInput( | ||
field. | field.label || '' | ||
); | ); | ||
label.on( | |||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
field.label = | |||
label.val(); | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
this. | |||
this. | |||
} | } | ||
); | ); | ||
| Line 4,284: | Line 4,281: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Label', | ||
label | |||
) | ) | ||
); | ); | ||
const | const description = | ||
this. | this.textarea( | ||
field | field.description || '' | ||
).addClass( | |||
'preload-editor-small-textarea' | |||
); | ); | ||
description.on( | |||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
field.description = | |||
description.val(); | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
this. | |||
this. | |||
} | } | ||
); | ); | ||
| Line 4,315: | Line 4,306: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Description', | ||
description | |||
) | ) | ||
); | ); | ||
const | const types = [ | ||
['text', 'Text'], | |||
['textarea', 'Textarea'], | |||
['number', 'Number'], | |||
['boolean', 'Boolean'], | |||
['select', 'Select'], | |||
['multiselect', 'Multi-select'], | |||
['page', 'Page'], | |||
['user', 'User'], | |||
['date', 'Date'] | |||
]; | |||
const typeSelect = | |||
this.select( | |||
types.map( | |||
([value, label]) => ({ | |||
value, | |||
label | |||
}) | |||
), | |||
field.type || 'text' | |||
); | |||
typeSelect.on( | |||
'change.preloadEditor', | |||
() => { | |||
field.type = | |||
typeSelect.val(); | |||
this.markConfigDirty(); | |||
this. | this.renderVariableConfigurationBySelector(); | ||
this. | this.renderConfigReviewState(); | ||
} | } | ||
); | ); | ||
| Line 4,364: | Line 4,349: | ||
card.append( | card.append( | ||
this.fieldRow( | this.fieldRow( | ||
' | 'Type', | ||
typeSelect | |||
) | ) | ||
); | ); | ||
const required = | |||
this. | this.checkbox( | ||
field | field.required === true | ||
); | |||
' | |||
) | required.on( | ||
'change.preloadEditor', | |||
() => { | |||
field.required = | |||
required.prop('checked'); | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | |||
); | |||
this. | card.append( | ||
this.fieldRow( | |||
' | 'Required', | ||
required | |||
) | ) | ||
); | ); | ||
const | const placeholder = | ||
this.textInput( | |||
field.placeholder || '' | |||
); | |||
placeholder.on( | |||
'input.preloadEditor', | |||
() => { | |||
const value = | |||
placeholder.val(); | |||
if (value) { | |||
field.placeholder = | |||
value; | |||
} else { | |||
delete field.placeholder; | |||
} | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | |||
); | |||
card.append( | |||
this.fieldRow( | |||
'Placeholder', | |||
placeholder | |||
) | |||
); | |||
const | const defaultInput = | ||
this. | this.textInput( | ||
field.default === undefined | |||
? '' | |||
: field.default | |||
); | ); | ||
defaultInput.on( | |||
'input.preloadEditor', | 'input.preloadEditor', | ||
() => { | () => { | ||
const | const value = | ||
defaultInput.val(); | |||
if (value) { | |||
field.default = | |||
value; | |||
if ( | |||
} else { | } else { | ||
field. | delete field.default; | ||
} | } | ||
this. | this.markConfigDirty(); | ||
this. | this.renderConfigReviewState(); | ||
} | } | ||
); | ); | ||
| Line 4,471: | Line 4,434: | ||
card.append( | card.append( | ||
this.fieldRow( | 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.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | |||
); | |||
); | |||
card.append( | |||
this.fieldRow( | |||
'Maximum rows', | |||
maxRows | |||
) | |||
); | ); | ||
} | |||
if ( | |||
this. | field.type === 'select' || | ||
field.type === 'multiselect' | |||
) { | |||
this.renderSelectConfiguration( | |||
card, | |||
field | |||
); | ); | ||
} | |||
const | /* | ||
this. | * Large delete button. | ||
* | |||
* PAGE never reaches this function because PAGE uses | |||
* createAutomaticPageCard(). | |||
*/ | |||
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>') | $('<div>') | ||
.addClass( | .addClass( | ||
'preload-editor- | 'preload-editor-variable-delete' | ||
); | ) | ||
.append(deleteButton) | |||
); | |||
return card; | |||
}, | |||
async deleteVariable(name) { | |||
name = | |||
String(name || '') | |||
.trim() | |||
.toUpperCase(); | |||
if ( | |||
!name || | |||
name === 'PAGE' | |||
) { | |||
return; | |||
} | |||
if ( | |||
this. | !this.state.config || | ||
!this.state.config.fields | |||
) { | |||
return; | |||
} | |||
delete this.state.config.fields[name]; | |||
if ( | |||
!Array.isArray( | |||
this.state.deletedVariables | |||
) | |||
) { | |||
this.state.deletedVariables = []; | |||
} | |||
if ( | |||
!this.state.deletedVariables.includes( | |||
name | |||
) | |||
) { | |||
this.state.deletedVariables.push( | |||
name | |||
); | |||
} | |||
/* | |||
* Keep the deleted variable selected so the user can | |||
* immediately see that it is now unconfigured/deleted. | |||
*/ | |||
this.state.selectedVariable = | |||
name; | |||
this.markConfigDirty(); | |||
this.renderVariablesBySelector(); | |||
this.renderVariableConfigurationBySelector(); | |||
this.renderAnalysisBySelector(); | |||
this.renderConfigReviewState(); | |||
this.setStatus( | |||
'Variable "' + | |||
name + | |||
'" removed from the configuration.', | |||
'warning' | |||
); | |||
}, | |||
renderSelectConfiguration( | |||
card, | |||
field | |||
) { | |||
const source = | |||
this.textInput( | |||
field.source || '', | |||
'category:Example' | |||
); | |||
source.on( | |||
'input.preloadEditor', | |||
() => { | |||
const value = | |||
source.val(); | |||
if ( | if (value) { | ||
field | field.source = value; | ||
} else { | } else { | ||
delete field | delete field.source; | ||
} | } | ||
this. | this.markConfigDirty(); | ||
this. | this.renderConfigReviewState(); | ||
}; | } | ||
); | |||
card.append( | |||
' | this.fieldRow( | ||
'Source', | |||
source, | |||
'Currently supported source format: category:Category Name' | |||
) | |||
); | |||
const sourcePrefix = | |||
this.textInput( | |||
field['source-prefix'] || '' | |||
); | |||
sourcePrefix.on( | |||
'input.preloadEditor', | |||
() => { | () => { | ||
const value = | |||
sourcePrefix.val(); | |||
if (value) { | |||
field['source-prefix'] = | |||
value; | |||
} else { | |||
delete field['source-prefix']; | |||
} | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | } | ||
); | ); | ||
card.append( | |||
this.fieldRow( | this.fieldRow( | ||
' | 'Source prefix', | ||
sourcePrefix, | |||
'Only source values beginning with this text are included.' | |||
) | |||
); | ); | ||
const excludeCategory = | |||
this.textInput( | |||
field.exclude && | |||
field.exclude.category || | |||
'' | |||
); | |||
excludeCategory.on( | |||
'input.preloadEditor', | |||
() => { | |||
const value = | |||
excludeCategory.val(); | |||
if (value) { | |||
field.exclude = | |||
Object.assign( | |||
{}, | |||
field.exclude || {}, | |||
{ | |||
category: value | |||
} | |||
); | |||
} else if ( | |||
field.exclude | |||
) { | |||
delete field.exclude.category; | |||
if ( | |||
!Object.keys( | |||
field.exclude | |||
).length | |||
) { | |||
delete field.exclude; | |||
} | |||
} | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | |||
); | |||
); | |||
card.append( | |||
this.fieldRow( | |||
'Exclude category', | |||
excludeCategory | |||
) | |||
); | |||
card.append( | |||
this.createTransformControls( | |||
field, | |||
'value', | |||
'Value transformation' | |||
), | |||
this.createTransformControls( | |||
this. | 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( | |||
this. | 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.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
} | } | ||
); | |||
card.append( | |||
this. | this.fieldRow( | ||
'Manual options', | |||
options, | |||
'One per line. Optional label format: value | label. Leave empty when using a source.' | |||
) | |||
); | |||
}, | |||
createTransformControls( | |||
field, | |||
property, | |||
const | title | ||
) { | |||
const wrapper = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-transform' | |||
); | |||
$('<h4>') | |||
.text(title) | |||
.appendTo(wrapper); | |||
); | |||
const existing = | |||
field[property] && | |||
typeof field[property] === 'object' | |||
? field[property] | |||
: {}; | |||
this. | 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 || '' | |||
); | ); | ||
this. | const prefix = | ||
this.textInput( | |||
existing.prefix || '' | |||
); | |||
this. | const suffix = | ||
this.textInput( | |||
existing.suffix || '' | |||
); | |||
this. | const from = | ||
this.textInput( | |||
existing.from || '' | |||
); | ); | ||
const to = | |||
this.textInput( | |||
existing.to || '' | |||
' | |||
); | ); | ||
const controls = | |||
$('<div>') | |||
.addClass( | |||
'preload-editor-transform-controls' | |||
); | |||
this. | 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 (validation.errors.length) { | if ( | ||
this.showValidationErrors( | type === 'remove-prefix' && | ||
validation | prefix.val() | ||
); | ) { | ||
object = { | |||
this.saveDraft(); | transform: type, | ||
return; | prefix: | ||
} | prefix.val() | ||
}; | |||
const configTitle = | } else if ( | ||
title.replace( | type === 'remove-suffix' && | ||
/\/+$/, | suffix.val() | ||
'' | ) { | ||
) + | object = { | ||
'/config.json'; | transform: type, | ||
suffix: | |||
this.setStatus( | suffix.val() | ||
'Saving preload and config...', | }; | ||
'loading' | } else if ( | ||
); | type === 'replace' && | ||
from.val() | |||
try { | ) { | ||
const preloadExists = | object = { | ||
await this.pageExists(title); | transform: type, | ||
from: | |||
from.val(), | |||
to: | |||
to.val() | |||
}; | |||
} | |||
if (object) { | |||
field[property] = object; | |||
} else { | |||
delete field[property]; | |||
} | |||
this.markConfigDirty(); | |||
this.renderConfigReviewState(); | |||
}; | |||
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 / review | |||
// ------------------------------------------------------------ | |||
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 | |||
); | |||
}, | |||
/* | |||
* Kept as a tiny compatibility helper because other parts | |||
* of the gadget may still call it. | |||
*/ | |||
renderConfigPreview(container) { | |||
if (container) { | |||
container.text( | |||
this.getConfigJSON() | |||
); | |||
} | |||
}, | |||
renderConfigPreviewBySelector() { | |||
this.renderConfigReviewState(); | |||
this.renderAnalysisBySelector(); | |||
}, | |||
renderConfigReviewState() { | |||
/* | |||
* The actual JSON is no longer rendered permanently. | |||
* The review dialog always reads getConfigJSON() when | |||
* opened, so it is automatically up to date. | |||
*/ | |||
}, | |||
// ------------------------------------------------------------ | |||
// Save | |||
// ------------------------------------------------------------ | |||
async getSaveTitle() { | |||
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 null; | |||
} | |||
title = result; | |||
this.state.preloadTitle = | |||
title; | |||
} | |||
return title; | |||
}, | |||
validateConfigBeforeSave() { | |||
const errors = []; | |||
const warnings = []; | |||
const title = | |||
String( | |||
this.state.preloadTitle || '' | |||
).trim(); | |||
if (!title) { | |||
errors.push( | |||
'A preload title is required.' | |||
); | |||
} | |||
if ( | |||
title && | |||
!/\/Preload$/i.test(title) | |||
) { | |||
warnings.push( | |||
'The title does not end with "/Preload".' | |||
); | |||
} | |||
/* | |||
* Reanalyze the current editor so config validation | |||
* always corresponds to what is currently being edited. | |||
*/ | |||
const content = | |||
this.getEditorContent(); | |||
const analysis = | |||
this.analyzePreload( | |||
this.extractIncludeonly( | |||
content | |||
) | |||
); | |||
this.state.config = | |||
this.prepareConfigForVariables( | |||
this.state.config || | |||
this.emptyConfig(), | |||
analysis | |||
); | |||
const validation = | |||
this.validateConfig( | |||
this.state.config, | |||
analysis | |||
); | |||
errors.push( | |||
...validation.errors | |||
); | |||
warnings.push( | |||
...validation.warnings | |||
); | |||
return { | |||
errors, | |||
warnings, | |||
valid: | |||
errors.length === 0 | |||
}; | |||
}, | |||
async savePreloadOnly() { | |||
const title = | |||
await this.getSaveTitle(); | |||
if (!title) { | |||
return; | |||
} | |||
this.state.preloadContent = | |||
this.getEditorContent(); | |||
const validation = | |||
this.validateBeforeSave(); | |||
if (validation.errors.length) { | |||
this.showValidationErrors( | |||
validation | |||
); | |||
this.saveDraft(); | |||
return; | |||
} | |||
this.setStatus( | |||
'Saving preload...', | |||
'loading' | |||
); | |||
try { | |||
const exists = | |||
await this.pageExists(title); | |||
await this.savePage( | |||
title, | |||
this.state.preloadContent, | |||
exists | |||
? 'Updated preload with Preload Editor' | |||
: 'Created preload with Preload Editor', | |||
!exists | |||
); | |||
this.state.mode = 'edit'; | |||
this.state.preloadSource = | |||
this.state.preloadContent; | |||
this.state.preloadDirty = | |||
false; | |||
this.updateDirtyState(); | |||
if (!this.state.dirty) { | |||
this.clearDraft(title); | |||
} else { | |||
this.saveDraft(); | |||
} | |||
this.setStatus( | |||
'Preload saved successfully.', | |||
'success' | |||
); | |||
mw.notify( | |||
'Preload saved.', | |||
{ | |||
type: 'success' | |||
} | |||
); | |||
} catch (error) { | |||
console.error( | |||
'Preload Editor save:', | |||
error | |||
); | |||
this.saveDraft(); | |||
this.setStatus( | |||
error.message || | |||
'Could not save preload.', | |||
'error' | |||
); | |||
mw.notify( | |||
error.message || | |||
'Could not save preload.', | |||
{ | |||
type: 'error' | |||
} | |||
); | |||
} | |||
}, | |||
async saveConfigOnly() { | |||
const title = | |||
await this.getSaveTitle(); | |||
if (!title) { | |||
return; | |||
} | |||
const validation = | |||
this.validateConfigBeforeSave(); | |||
if (validation.errors.length) { | |||
this.showValidationErrors( | |||
validation | |||
); | |||
this.saveDraft(); | |||
return; | |||
} | |||
const configTitle = | |||
title.replace( | |||
/\/+$/, | |||
'' | |||
) + | |||
'/config.json'; | |||
this.setStatus( | |||
'Saving config...', | |||
'loading' | |||
); | |||
try { | |||
const configExists = | |||
await this.pageExists( | |||
configTitle | |||
); | |||
await this.savePage( | |||
configTitle, | |||
this.getConfigJSON(), | |||
configExists | |||
? 'Updated preload config with Preload Editor' | |||
: 'Created preload config with Preload Editor', | |||
!configExists | |||
); | |||
this.state.configDirty = | |||
false; | |||
this.updateDirtyState(); | |||
if (!this.state.dirty) { | |||
this.clearDraft(title); | |||
} else { | |||
this.saveDraft(); | |||
} | |||
this.setStatus( | |||
'Config saved successfully.', | |||
'success' | |||
); | |||
mw.notify( | |||
'Preload config saved.', | |||
{ | |||
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() { | |||
const title = | |||
await this.getSaveTitle(); | |||
if (!title) { | |||
return; | |||
} | |||
this.state.preloadContent = | |||
this.getEditorContent(); | |||
this.reanalyze(); | |||
const validation = | |||
this.validateBeforeSave(); | |||
if (validation.errors.length) { | |||
this.showValidationErrors( | |||
validation | |||
); | |||
this.saveDraft(); | |||
return; | |||
} | |||
const configTitle = | |||
title.replace( | |||
/\/+$/, | |||
'' | |||
) + | |||
'/config.json'; | |||
this.setStatus( | |||
'Saving preload and config...', | |||
'loading' | |||
); | |||
try { | |||
const preloadExists = | |||
await this.pageExists(title); | |||
await this.savePage( | |||
title, | |||
this.state.preloadContent, | |||
preloadExists | |||
? 'Updated preload with Preload Editor' | |||
: 'Created preload with Preload Editor', | |||
!preloadExists | |||
); | |||
const configExists = | |||
await this.pageExists( | |||
configTitle | |||
); | |||
await this.savePage( | |||
configTitle, | |||
this.getConfigJSON(), | |||
configExists | |||
? 'Updated preload config with Preload Editor' | |||
: 'Created preload config with Preload Editor', | |||
!configExists | |||
); | |||
this.state.mode = 'edit'; | |||
this.state.preloadTitle = title; | |||
this.state.preloadSource = | |||
this.state.preloadContent; | |||
this.state.preloadContent | |||
this.state.preloadDirty = | |||
false; | |||
this.state.configDirty = | |||
false; | |||
this. | this.updateDirtyState(); | ||
this.clearDraft(title); | this.clearDraft(title); | ||