/*
* Copyright 2014 Anyware Services
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* This tool does allow to edit and execute a script
* @private
*/
Ext.define('Ametys.plugins.cms.search.ScriptTool', {
extend: "Ametys.plugins.cms.search.ContentSearchTool",
/**
* @cfg {Boolean} [readOnly=false] set to 'true' to open tool in read-only mode
*/
readOnly: false,
searchButtonText: "{{i18n plugin.cms:UITOOL_SEARCH_BUTTON_EXECUTE}}",
searchButtonTooltipText: "{{i18n plugin.cms:UITOOL_SEARCH_BUTTON_EXECUTE_DESC}}",
searchButtonIconCls: 'ametysicon-play124',
/**
* @private
* @property {String} _asyncExecuteButtonText The text for the "async execute" button
*/
_asyncExecuteButtonText: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_TAB_EXECUTE_ASYNC_LABEL}}",
/**
* @private
* @property {String} _asyncExecuteButtonTooltipText The text for the "async execute" tooltip
*/
_asyncExecuteButtonTooltipText: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_TAB_EXECUTE_ASYNC_DESC}}",
/**
* @private
* @property {String} _asyncExecuteButtonIconCls The CSS classes for "async execute" button
*/
_asyncExecuteButtonIconCls: 'ametysicon-play124',
/**
* @private
* @property {String} _asyncExecuteButtonIconCls The CSS classes for "async execute" tooltip
*/
_asyncExecuteButtonTooltipIconCls: 'ametysicon-play124 decorator-ametysicon-arrow-circle-right-double',
/**
* Does not support export (to prevent reexecuting the script every time).
* Force export buttons to be disabled
* @private
*/
ignoreExportButton: true,
/**
* Does not support print (to prevent reexecuting the script every time).
* Force print button to be disabled
* @private
*/
ignorePrintButton: true,
/**
* The number of lines in the editor footer, currently forced at 3.
* @private
*/
_footerLineCount: 3,
/**
* Toggle the edition of the header and footer of the editor
* @private
*/
_allowHeaderFooterEdit: false,
getMBSelectionInteraction: function()
{
return Ametys.tool.Tool.MB_TYPE_LISTENING;
},
constructor: function(config)
{
config.enablePagination = false;
this.callParent(arguments);
},
// Does not support formating, because the columns are calculated from the script
// This method is set to undefined to disable buttons depending on it
getCurrentFormatting: undefined,
setParams: function(params)
{
var initialTitle = this.getInitialConfig('title') || '';
if (params.title && !Ext.String.startsWith(params.title, initialTitle + ' - '))
{
params.title = initialTitle + ' - ' + params.title;
this.setTitle(params.title);
}
if (params.values && params.values.script)
{
this._allowHeaderFooterEdit = true;
this._editor.setValue(params.values.script);
this._allowHeaderFooterEdit = false;
}
this.readOnly = params.readOnly === true;
this.callParent(arguments);
},
createPanel: function()
{
this.searchPanel = this._createSearchFormPanel();
this.console = this._createConsolePanel();
this.mainPanel = new Ext.Panel({
region: 'center',
layout: 'border',
cls: 'uitool-script',
items: Ext.Array.merge(
[this.searchPanel],
this.getBottomPanelItems(),
[this.console]
)
});
return this.mainPanel;
},
_createConsolePanel: function()
{
return new Ext.Panel({
region: 'east',
title: "{{i18n plugin.cms:UITOOL_SCRIPT_CONSOLE}}",
html: '',
scrollable: true,
collapsible: true,
collapsed: true,
cls: 'coreui-script-result-tool',
width: 300,
split: true
});
},
/**
* Creates the script editor
* @private
*/
_createSearchFormPanel: function ()
{
this.form = this._createScriptFormPanel();
var me = this;
var cfg = this._getSearchFormPanelConfig();
var panelCfg = Ext.apply (cfg, {
region: 'north',
split: true,
border: false,
ui: 'light',
header: {
titlePosition: 1
},
stateful: true,
stateId: this.getId() + '$script-search-form',
scrollable: true,
bodyPadding: 10,
title: "{{i18n plugin.cms:UITOOL_SCRIPT_SEARCH_CRITERIA}}",
collapsible: true,
titleCollapse: true,
animCollapse: false,
minHeight: 250,
// maxHeight: Ametys.plugins.cms.search.ContentSearchTool.FORM_DEFAULT_MAX_HEIGHT,
layout: {
type: 'vbox',
align: 'stretch'
},
items: [this.form],
listeners: {
'resize': function (cmp, width, height)
{
if (this._oldHeight && height != this._oldHeight && !this.collapsed)
{
// The panel was manually resized: save new ratio
this._oldHeight = height;
this._heightRatio = height / me.mainPanel.getHeight();
}
}
},
applyState: function (state)
{
this._heightRatio = state.ratio;
},
getState: function ()
{
// Save the height ratio
return {
ratio: this._heightRatio
}
}
});
var panel = Ext.create ('Ext.Panel', panelCfg);
return panel;
},
/**
* @private
* Create the form for script editor
* @return {Ext.form.FormPanel} the form
*/
_createScriptFormPanel: function()
{
var footerLine1 = "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_FOOTER_HINT_LINE1}}".replace(/\r\n|\r|\n/, "");
var footerLine2 = "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_FOOTER_HINT_LINE2}}".replace(/\r\n|\r|\n/, "");
this._editor = Ext.create ("Ametys.form.field.CodeAdvanced", {
cls: 'uitool-script-editor',
name: "script",
mode: 'typescript',
readOnly: this.readOnly,
flex: 1,
stateful: true,
stateId: this.getId() + "$code",
value: "function main() {\n \n \n \n \n}\n" + footerLine1 + "\n" + footerLine2,
listeners: {
beforechange: {
fn: function (editor, newValue, oldValue, event) {
if (!this._allowHeaderFooterEdit
&& event.changes.filter(change => change.range.startLineNumber == 1 || change.range.endLineNumber > oldValue.split("\n").length - this._footerLineCount).length > 0)
{
let me = this;
window.setTimeout(function() { me._updateDecorations(editor); }, 1); // Depending on the modification we refuse, the decoration may be destroyed... (select a few line including the last ones, and type one character for example)
return false;
}
},
scope: this
},
change: {
fn: function (editor, newValue, oldValue) {
this._updateDecorations(editor);
},
scope: this
},
initialize: {
fn: function (editor) {
let me = this;
window.setTimeout(function() { me._updateDecorations(editor); }, 0);
let ed = editor._monaco;
// Replace the default Ctrl+A
ed.addAction({
id: 'custom-select-all',
label: 'Select All Editable',
keybindings: [
monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyA
],
precondition: null,
keybindingContext: null,
run: function(ed) { me._selectAll(); }
});
// load definition for Ametys API
Ametys.data.ServerComm.send({
plugin: 'core-ui',
url: 'script/typescript-definitions.d.ts',
callback: {
handler: function(response)
{
monaco.languages.typescript.javascriptDefaults.addExtraLib(response.textContent, 'ts:ametys.d.ts');
monaco.languages.typescript.typescriptDefaults.addExtraLib(response.textContent, 'ts:ametys.d.ts');
},
scope: this
},
responseType: 'text',
waitMessage: { target: me.getContentPanel() },
errorMessage: true
});
},
scope: this
}
}
});
var formCfg = {
itemId: 'script-search-form-panel',
scrollable: true,
flex: 1,
items: this._editor,
layout: 'fit',
getJsonValues: function()
{
var values = {},
fields = this.form.getFields().items,
fLen = fields.length,
field;
for (var f = 0; f < fLen; f++)
{
field = fields[f];
values[field.getName()] = field.getJsonValue();
}
return values;
}
};
return Ext.create ('Ext.form.FormPanel', formCfg);
},
_getSearchFormPanelBBar: function()
{
var items = this.callParent(arguments);
// insert just after the 'search' button
var searchBtnIndex = Ext.Array.indexOf(
items.map(function(b) {
return b.itemId;
}),
'search'
);
var searchBtn = items[searchBtnIndex];
var searchSubItem = Ext.applyIf({
itemId: 'search-menu-item'
}, searchBtn); // copy search btn
var menu = {
items: [
searchSubItem,
{
itemId: 'search-async-menu-item',
text: this._asyncExecuteButtonText,
iconCls: this._asyncExecuteButtonIconCls,
handler: this._executeAsync,
scope: this,
tooltip: {
title: this._asyncExecuteButtonText,
text: this._asyncExecuteButtonTooltipText,
glyphIcon: this._asyncExecuteButtonTooltipIconCls,
inribbon: false
}
}
]
};
var splitBtn = Ext.applyIf({
xtype: 'splitbutton',
menu: menu
}, searchBtn); // we have to keep its item id otherwise superclass will not be able to retrieve it !
var removeCount = 1;
Ext.Array.replace(items, searchBtnIndex, removeCount, [splitBtn]);
return items;
},
/**
* @private
* Launches the search/the script asynchronously.
* @param {Ext.button.Button} btn The clicked button
*/
_executeAsync: function(btn)
{
var script = this._editor.getValue();
// remove the surrounding function: function main { ... }
script = script.substring(script.indexOf('{') + 1, script.lastIndexOf('}'));
if (Ametys.plugins.coreui.script.ScriptParameters.hasScriptParameters(script))
{
Ametys.log.ErrorDialog.display({
title: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_PARAMETERS_ASYNC}}",
text: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_PARAMETERS_ASYNC_ERROR}}",
category: "Ametys.plugins.coreui.script.ScriptParameters"
});
return;
}
Ametys.plugins.coreui.script.AsyncScriptDialog.open({
serverCallFn: callMethodExecuteAsyncScript,
scope: this
});
function callMethodExecuteAsyncScript(params)
{
this.serverCall(
"add",
[
"{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_EXECUTE_ASYNC_TASK_TITLE}}",
"{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_EXECUTE_ASYNC_TASK_DESCRIPTION}}",
"NOW",
"",
"org.ametys.core.schedule.Script",
{
"org.ametys.core.schedule.Script$recipient": params.recipient,
"org.ametys.core.schedule.Script$script": script,
"org.ametys.core.schedule.Script$workspace": Ametys.WORKSPACE_NAME,
"org.ametys.core.schedule.Script$selection": this._convertedSelection || '',
"org.ametys.core.schedule.Script$model": "search-ui.default"
}
],
Ext.bind(_executeAsyncScriptCb, this),
{
waitMessage: {
msg: "{{i18n plugin.cms:UITOOL_SEARCH_WAITING_MESSAGE}}",
target: this.mainPanel
},
errorMessage: true
}
);
}
function _executeAsyncScriptCb()
{
Ametys.notify({
title: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_EXECUTE_ASYNC_NOTIFY_TITLE}}",
description: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPT_EXECUTE_ASYNC_NOTIFY_DESC}}"
});
}
},
_onFormInitialized: function()
{
// Nothing
},
/**
* @inheritdoc
*/
_getAdditionalExtensions: function (location)
{
return Ametys.plugins.cms.search.ScriptToolExtensions.getAdditionalButtons(location);
},
/**
* @inheritdoc
*/
_getStoreCfg: function()
{
return {
remoteSort: false,
proxy: {
type: 'memory',
reader: this._getReaderCfg()
},
listeners: {
'beforeload': this._onBeforeLoad,
'load': this._onLoad,
scope: this
},
sortOnLoad: true
};
},
_callExecuteScript: function(script, parameters, callback)
{
Ametys.data.ServerComm.callMethod({
role: 'org.ametys.cms.scripts.CmsScriptHandler',
methodName: 'executeScript',
parameters: [{
script: script,
parameters: parameters,
selection: this._convertedSelection || '',
model: "search-ui.default"
}],
callback: {
handler: callback,
scope: this
},
waitMessage: {
msg: "{{i18n plugin.cms:UITOOL_SEARCH_WAITING_MESSAGE}}",
target: this.mainPanel
},
errorMessage: true
});
},
/**
* @private
* Loads data in the gird store
*/
_loadDataInStore: function()
{
let me = this;
let script = this._editor.getValue() || null;
Ametys.plugins.coreui.script.ScriptParameters.askScriptParameters(script,
function(parameters) {
if (parameters)
{
let p = me.getParams();
let values = {};
for (let paramName of Object.keys(parameters))
{
values[paramName] = parameters[paramName].value;
}
p.parameters = values;
// Calling set params to save params correctly, but not this tool instance to avoid re-execution of the script
Ametys.tool.Tool.prototype.setParams.call(me, p)
}
me._callExecuteScript(script, parameters, executeScriptCb);
},
undefined, // title
undefined, // description
{ values: me.getParams().parameters } // existing values
);
function executeScriptCb(data)
{
this._updateColumns(data);
var contents = data.contents || [];
this.store.getProxy().setData(contents);
this.store.load();
this._updateConsole(data);
}
},
/**
* @private
* Updates the columns
* @param {Object} data The server data
*/
_updateColumns: function(data)
{
if (Ext.Object.isEmpty(data) || Ext.Object.isEmpty(data.columns))
{
return;
}
var rawColumns = data.columns;
var fields = Ametys.plugins.cms.search.SearchGridHelper.getFieldsFromJson(rawColumns);
this._addDefaultContentFields(fields);
var columns = Ametys.plugins.cms.search.SearchGridHelper.getColumnsFromJson(rawColumns, true, this.grid, true);
var sorters = Ametys.plugins.cms.search.SearchGridHelper.getSortersFromJson(columns, this.grid);
// Update model
Ext.data.schema.Schema.get('default').getEntity(this._modelName).replaceFields(fields, true);
this.grid.reconfigure(this.store, columns);
this.store.sorters = null; // There is no other way to clean old sorters...
this.store.setSorters(sorters);
},
_configureProxy: function(data)
{
// Do nothing. Prevents reconfiguring the store proxy every time
},
/**
* Execute the script
* @private
*/
_launchSearch: function()
{
this.showUpToDate();
if (this._mask)
{
this._mask.hide();
delete this._mask;
}
this._executeScript();
this._message = Ametys.data.ServerComm._messages[Ametys.data.ServerComm._messages.length - 1];
},
/**
* Execute the script
* @private
*/
_executeScript: function ()
{
this._convertedSelection = this._convertCurrentSelection();
this._loadDataInStore();
},
/**
* @private
* Convert the current message bus selection to a script.
* @return {String} A comma separated list of script instruction to resolve current bus selection ids.
*/
_convertCurrentSelection: function ()
{
var message = Ametys.message.MessageBus.getCurrentSelectionMessage();
var contentTargets = message.getTargets(Ametys.message.MessageTarget.CONTENT);
var contents = [];
for (var i = 0; contentTargets != null && i < contentTargets.length; i++)
{
contents.push(contentTargets[i].getParameters().id);
}
return contents;
},
/**
* @inheritdoc
*/
_getResultGridCfg: function()
{
var cfg = this.callParent(arguments);
// disable pagination
cfg.enablePagination = false;
// add info toolbar
cfg.dockedItems = cfg.dockedItems || [];
cfg.dockedItems.push(this._getInfoToolBarCfg());
return cfg;
},
/**
* @protected
* Get the config to be used to create the info tool bar.
* @param {Object} config The result grid config object.
* @return {Object} The config object
*/
_getInfoToolBarCfg: function(config)
{
return {
xtype: 'toolbar',
itemId: 'infobar',
cls: 'infobar',
dock: 'bottom',
items: [
{
xtype: 'component',
itemId: 'result',
cls: 'result',
html: "{{i18n plugin.cms:UITOOL_CONTENTEDITIONGRID_NO_RESULT}}"
}
]
};
},
/**
* @inheritdoc
*/
_onLoad: function (store, records, successful, operation)
{
this.callParent(arguments);
this._infoBar = this._infoBar || this.grid.getDockedComponent('infobar');
if (this._infoBar)
{
var limit = 100; // Hardcoded limit, must correspond to the server side limit.
this._infoBarResultCmp = this._infoBarResultCmp || this._infoBar.down('#result');
if (this._infoBarResultCmp)
{
if (!records || records.length == 0)
{
this._infoBarResultCmp.update("{{i18n plugin.cms:UITOOL_CONTENTEDITIONGRID_NO_RESULT}}");
}
else if (records.length == 1)
{
this._infoBarResultCmp.update("{{i18n UITOOL_SCRIPT_SEARCH_RESULT_TEXT_RESULT}}");
}
else
{
var totalCount = store.getCount();
var infoResult = Ext.String.format(
"{{i18n UITOOL_SCRIPT_SEARCH_RESULT_TEXT_RESULTS}}",
totalCount
);
if (totalCount > limit)
{
infoResult += ' ' + Ext.String.format(
"{{i18n UITOOL_SCRIPT_SEARCH_RESULT_TEXT_RESULTS_LIMIT}}",
limit
);
}
this._infoBarResultCmp.update(infoResult);
}
}
}
},
/**
* @private
* Updates the console
* @param {Object} data The server data
*/
_updateConsole: function(data)
{
var result = data.result !== undefined && data.result !== null ? data.result : '[{{i18n UITOOL_SCRIPT_SEARCH_RESULT_TEXT_NO_RESULTS}}]';
var stdout = data.output || '';
var stderr = data.error || data.message || '';
if (Ext.isArray(result) || Ext.isObject(result))
{
result = Ext.JSON.prettyEncode(result);
}
this.console.body.update(Ametys.plugins.coreui.script.ScriptToolHelper.formatConsoleOutput(data));
this.console.expand();
},
/**
* Called by the key binding Ctrl-A, overrides the default selection to only the editable lines.
* @private
*/
_selectAll: function()
{
if (this._editor)
{
let ed = this._editor._monaco;
var model = ed.getModel();
var totalLines = model.getLineCount();
var startLine = 2; // Ligne après le header
var endLine = totalLines - this._footerLineCount; // Ligne avant le footer
if (endLine >= startLine) {
var endColumn = model.getLineMaxColumn(endLine);
ed.setSelection(new monaco.Range(startLine, 1, endLine, endColumn));
ed.revealLineInCenter(startLine);
}
}
},
_getDefaultButtons: function()
{
var items = this.callParent(arguments);
items.push({
// Help
itemId: 'help',
iconCls: 'ametysicon-sign-question',
handler: this._openHelp,
scope: this,
tooltip: {
title: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPTHELP_TITLE}}",
text: "{{i18n plugin.core-ui:PLUGINS_CORE_UI_TOOLS_SCRIPTHELP_DESCRIPTION}}",
glyphIcon: 'ametysicon-sign-question',
inribbon: false
}
});
return items;
},
_openHelp: function()
{
Ametys.tool.ToolsManager.openTool('uitool-admin-scripthelp');
},
_updateDecorations: function(editor)
{
let model = editor._monaco.getModel();
let totalLines = model.getLineCount();
let decorations = [];
// Décoration pour la première ligne
decorations.push({
range: new monaco.Range(1, 1, 1, model.getLineMaxColumn(1)),
options: {
isWholeLine: true,
className: 'code-readonly-line',
glyphMarginClassName: 'code-readonly-glyph'
}
});
// Décoration pour les dernières lignes (footer)
for (var i = 0; i < this._footerLineCount; i++)
{
var lineNumber = totalLines - this._footerLineCount + i + 1;
decorations.push({
range: new monaco.Range(lineNumber, 1, lineNumber, model.getLineMaxColumn(lineNumber)),
options: {
isWholeLine: true,
className: 'code-readonly-line',
glyphMarginClassName: 'code-readonly-glyph'
}
});
}
// Appliquer les décorations
this._decorationIds = model.deltaDecorations(this._decorationIds || [], decorations);
}
});