AEM CQ 6 - Debugging AEM JSPs with IntelliJ IDEA 12

Goal


This post is on Debugging AEM Jsps ( or custom component jsps ) for better understanding of request flow and rendering, eg. textfield component used in cq:dialog

Product Jsps are available in CRX; so we'll start with a new project in Intellij, sync code from CRX and set up debugging... At the end of this article you should be able to debug the textfield component jsp...

Check AEM Documentation for more information on setting up AEM/CQ projects in Intellij IDEA




Sync code from CRX


1) Open IntelliJ IDEA, click File -> New Project... select Java Module and give project name (aem) and location (C:\dev\code\projects\aem)

2) Create folders META-INF/vault under aem project folder, file META-INF/vault/filter.xml

3) Add the filter for checking out /libs/granite/ui from CRX (using vault). The filter.xml

<?xml version="1.0" encoding="UTF-8"?>
<workspaceFilter version="1.0">
    <filter root="/libs/granite/ui"/>
</workspaceFilter>

4) To add vault commands in IntelliJ check this post; vault is not really necessary here, if you are not interested in setting up vault, go to CRXDE Lite (http://localhost:4502/crx/de) create a package with filter /libs/granite/ui, download and unzip to the folder created above (C:\dev\code\projects\aem).


Configure Source Code


5) The code was checked-out; open module settings and add jcr_root to sources

6) Open any checked out file (eg. C:\dev\code\projects\aem\jcr_root\libs\granite\ui\components\foundation\form\multifield\render.jsp) and you can see red markings all over, as no dependent libraries were added...

7) At this point open Dependencies tab of module, create a Global Library and go wild, if you have enough memory just add all maven dependency jars available in your local repo (or be sane, pick up the necessary jars and add them to your library)

8) Goto IntelliJ Settings -> Plugins -> Install JetBrains plugin and add JSR 45 Integration



Setup Debugger


9) Goto Intellij -> Run -> Edit Configurations -> + (Add New Configuration) -> JSR 45 Compatible Server -> Remote



10) Give the debugger a name CQ, remove any Before Launch steps (as we are not really building anything)



11) Add Application server Generic, any start page say Geometrixx English and leave everything default

12) Click on tab Startup/Connection, Debug. Change the port number or leave it default (in the below pic it was changed to 5005)



13) Open the module and add a Web facet

14) Remove the default Web Resource Directory to configure jcr_root

15) Add the project module sub folder jcr_root (C:\dev\code\projects\aem\jcr_root) as Web Resource Directory



16) Copy the debug arguments from Step 12 and add it in your CQ start script

       java -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,suspend=n,server=y -XX:MaxPermSize=512m -Xmx1024M -jar cq-quickstart-6.0.0.jar -nofork

17) Restart CQ

18) Return to IntelliJ IDEA when CQ is up, open component jsp to debug (eg. C:\dev\code\projects\aem\jcr_root\libs\granite\ui\components\foundation\form\textfield\render.jsp) set a debug point, select debugger CQ (created in step 12 above) and click on bug icon to connect the debugger. Open any dialog with a textfield component say Page Properties dialog of Geometrixx english http://localhost:4502/editor.html/content/geometrixx/en.html and the code execution should stop at debug point in textfield component jsp


AEM - Browser Extension to Open Publish Page in Author

Goal


Develop simple Chrome and Firefox browser Access and Edit Extension to open a published page in author instance. Assuming author is on default port http://localhost:4502 and publish on http://localhost:4503, this extension allows user to open a published page, for authoring in a new browser tab. Eg. if user has published page http://localhost:4503/content/geometrixx-outdoors/en.html open in a browser tab, clicking on the extension opens Classic UI http://localhost:4502/cf#/content/geometrixx-outdoors/en.html in new tab (if not logged in on author instance, the login page is shown first)

Demo

A very useful chrome extension that every AEM developer should have is available on chrome web store


Chrome





Firefox




Develop for Chrome


1) Create a folder C:\dev\open-page-in-author-browser-ext\chrome

2) Create file C:\dev\open-page-in-author-browser-ext\chrome\manifest.json with following data. Script eaem.js is the runtime for extension with necessary JS logic to open a new tab

{
    "manifest_version": 2,
    "name": "Access and Edit",
    "description": "Access and Editing Tool for Adobe Experience Manager",
    "version": "1.0",

    "browser_action": {
        "default_icon": "pencil.png"
    },

    "permissions": [
        "tabs",
        "http://*/*",
        "https://*/*"
    ],

    "background": {
        "scripts": ["eaem.js"]
    }
}

3) Add the pencil icon to C:\dev\open-page-in-author-browser-ext\chrome

4) Create file C:\dev\open-page-in-author-browser-ext\chrome\eaem.js and add following code

(function(){
    var authorPrefix = 'http://localhost:4502';

    chrome.browserAction.onClicked.addListener(function(tab) {
        chrome.tabs.getSelected(null, function(tab){
            var parser = document.createElement('a');
            parser.href = tab.url;

            var path = "";

            if(parser.pathname.indexOf("/cf") == 0){
                path = parser.pathname + parser.hash + parser.search;
            }else{
                path = "/cf#" + parser.pathname + parser.search;
            }

            path = authorPrefix + path;

            chrome.tabs.create({url : path});
        });
    });
})();

5) Chrome enables only web store extensions (and this extension is not web store worthy :) ); to use this extension use chrome developer mode and load it


6) A small pencil icon should appear on the top right of chrome; open any page on publish in a tab eg. http://localhost:4503/content/geometrixx-outdoors/en/women.html, click on the pencil and chrome opens the same page on author in new tab (a login page if user was not already authenticated)

8) Download code as zip

Develop for Firefox


1) Create a folder C:\dev\open-page-in-author-browser-ext\firefox

2) Create file C:\dev\open-page-in-author-browser-ext\firefox\install.rdf with following xml. Just bragging about yourself :)

<?xml version="1.0"?>
<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:em="http://www.mozilla.org/2004/em-rdf#">
    <Description about="urn:mozilla:install-manifest">
        <em:id>fake@experience-aem.blogspot.com</em:id>
        <em:name>Access and Edit</em:name>
        <em:description>Access and Editing Tool for Adobe Experience Manager</em:description>
        <em:version>1.0</em:version>
        <em:creator>Experience AEM</em:creator>
        <em:homepageURL>http://experience-aem.blogspot.com</em:homepageURL>
        <em:type>2</em:type>
        <em:targetApplication>
            <Description>
                <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id>
                <em:minVersion>4.0</em:minVersion>
                <em:maxVersion>14.*</em:maxVersion>
            </Description>
        </em:targetApplication>
    </Description>
</RDF>

3) Create file C:\dev\open-page-in-author-browser-ext\firefox\chrome.manifest with following content, overlaying the browser chrome for adding your extension pencil button; the extension will be available in eaem folder

content eaem content/

overlay chrome://browser/content/browser.xul  chrome://eaem/content/eaem.xul

4) Create file C:\dev\open-page-in-author-browser-ext\firefox\content\pencil.png

5) Create file C:\dev\open-page-in-author-browser-ext\firefox\content\eaem.properties with author hostname property (read in extension JS logic)

                     author.prefix = http://localhost:4502

6) Create file C:\dev\open-page-in-author-browser-ext\firefox\content\eaem.xul, overlay file assembling the extension (adding pencil, click action etc.)

<?xml version="1.0"?>
<overlay id="eaem-browser-overlay"
         xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">

    <script type="application/x-javascript" src="chrome://eaem/content/eaem.js" />

    <stringbundleset id="stringbundleset">
        <stringbundle id="eaem-string-bundle"
                      src="chrome://eaem/content/eaem.properties" />
    </stringbundleset>

    <toolbarpalette id="BrowserToolbarPalette">
        <toolbarbutton id="eaem-navbar-button"
                       class="toolbarbutton-1 chromeclass-toolbar-additional"
                       label="Access Edit"
                       image="chrome://eaem/content/pencil.png"
                       tooltiptext="Access and Editing"
                       oncommand="ExperienceAEM.AccessAndEditing.open(event)">
        </toolbarbutton>
    </toolbarpalette>

</overlay>


7) Create file C:\dev\open-page-in-author-browser-ext\firefox\content\eaem.js with necessary logic to open page in new tab

if ("undefined" == typeof(ExperienceAEM)) {
    var ExperienceAEM = {};
};

ExperienceAEM.AccessAndEditing = {
    init : function () {
        var navbar = document.getElementById("nav-bar");
        var newset = navbar.currentSet + ',eaem-navbar-button';
        navbar.currentSet = newset;
        navbar.setAttribute("currentset", newset );
        document.persist("nav-bar", "currentset");
    },

    open: function(aEvent) {
        var bundle = document.getElementById("eaem-string-bundle");

        if(!bundle){
            window.alert("Missing 'eaem.properties'");
            return;
        }

        var hostPrefix = bundle.getString("author.prefix");

        if(!hostPrefix){
            window.alert("Missing author prefix 'author.prefix' in 'eaem.properties'");
            return;
        }

        var loc = window.content.location;
        var path = "";

        if(loc.pathname.indexOf("/cf") == 0){
            path = loc.pathname + loc.hash + loc.search;
        }else{
            path = "/cf#" + loc.pathname + loc.search;
        }

        window.BrowserOpenTab();
        window.content.location.href = hostPrefix + path;
    }
};

window.addEventListener("load", function(){ ExperienceAEM.AccessAndEditing.init();  }, false);

8) To build the extension, use standard jar command, at C:\dev\open-page-in-author-browser-ext

                     jar cvf eaem_access_edit.xpi -C firefox .

9) Download the extension as zip

AEM 6 SP1 - TouchUI Richtext Editor Color Picker Plugin

Goal


For a better implementation check this post

Create a Color Picker Plugin (CUI.Colorpicker) for the Touch UI Rich Text Editor (RTE). A Color Picker plugin for Classic UI RTE is available here

Demo shows dialog of foundation text component (/libs/foundation/components/text/dialog/items/tab1/items/text/rtePlugins) modified to add the color picker config. This is just for demonstration only (on Geometrixx pages), ideally the foundation components should never be altered...

Please leave a comment if you find bug / fix...

Demo | Package Install


Picker Configuration

Configure the palette colors in rtePlugins touchuicolorpicker node property colors as String[]


Inline View

Full Screen View



Color applied to text wrapped in span tag



Text in CRX node



Solution


1) Login to CRXDE Lite, create folder (nt:folder) /apps/rte-touch-ui-color-picker

2) Create clientlib (type cq:ClientLibraryFolder/apps/rte-touch-ui-color-picker/clientlib and set property categories of String type to rte.coralui2

3) Create file ( type nt:file ) /apps/rte-touch-ui-color-picker/clientlib/css.txt, add the following

                         color-picker.css

4) Create file (type nt:file) /apps/rte-touch-ui-color-picker/clientlib/color-picker.css, add the following code. This is palette button (available in font-family: font-family: AdobeIcons)

.eaem-touchui-color-picker::before {
    content: "\f156";
}

5) Create file ( type nt:file ) /apps/rte-touch-ui-color-picker/clientlib/js.txt, add the following

                         color-picker.js

6) Create file (type nt:file) /apps/rte-touch-ui-color-picker/clientlib/color-picker.js, add the following code

(function(){
    var ExperienceAEM = {
        TCP_UI_SETTING: "touchuicolorpicker#touchuicolorpicker",
        TCP_FEATURE: "touchuicolorpicker",
        TCP_DIALOG: "touchuicolorpickerdialog"
    };

    ExperienceAEM.CuiToolbarBuilder = new Class({
        toString: "EAEMCuiToolbarBuilder",

        extend: CUI.rte.ui.cui.CuiToolbarBuilder,

        _getUISettings: function(options) {
            var uiSettings = this.superClass._getUISettings(options);

            var items = uiSettings["inline"]["popovers"]["format"].items;

            if(items.indexOf(ExperienceAEM.TCP_UI_SETTING) == -1){
                items.push(ExperienceAEM.TCP_UI_SETTING);
            }

            items = uiSettings["fullscreen"]["toolbar"];

            if(items.indexOf(ExperienceAEM.TCP_UI_SETTING) == -1){
                items.splice(3, 0, ExperienceAEM.TCP_UI_SETTING);
            }

            //add the color picker css to ui settings of toolbar
            if(!this._getClassesForCommand(ExperienceAEM.TCP_UI_SETTING)){
                this.registerAdditionalClasses(ExperienceAEM.TCP_UI_SETTING, "coral-Icon eaem-touchui-color-picker");
            }

            return uiSettings;
        }
    });

    //the popover dialog
    ExperienceAEM.ColorPickerDialog = new Class({
        extend: CUI.rte.ui.cui.AbstractBaseDialog,

        toString: "EAEMColorPickerDialog",

        initialize: function(config) {
            //exec function passes the color value to plugin command
            this.exec = config.execute;
        },

        getDataType: function() {
            return ExperienceAEM.TCP_DIALOG;
        },

        attach: function(config, $container, editorKernel) {
            this.superClass.attach.call(this,config, $container, editorKernel);

            var self = this;

            //to removed previously selected color
            this.$dialog.on("click.rte-dialog", "button[data-type=\"delete\"]",
                function(e) {
                    self.colorPicker.$element.removeAttr("value");
                    self.apply();
                    e.stopPropagation();
                }
            );
        },

        apply: function() {
            this.hide();

            if(!this.colorPicker){
                return;
            }

            var $selection = this.colorPicker.$element.find(".colorpicker-holder").find(".selection");

            var hexCode = $selection.length > 0 ? $selection.find("span:last-child").html() : undefined;

            //pass the color value to command
            this.exec(hexCode);
        },

        cancel: function() {
            this.hide();
        }
    });

    //extend the CUI dialog manager to register color picker dialog
    ExperienceAEM.DialogManager = new Class({
        toString: "EAEMDialogManager",

        extend: CUI.rte.ui.cui.CuiDialogManager,

        create: function(dialogId, config) {
            if(dialogId !== ExperienceAEM.TCP_DIALOG){
                return this.superClass.create.call(this,dialogId, config);
            }

            var context = this.editorKernel.getEditContext();
            var $container = CUI.rte.UIUtils.getUIContainer($(context.root));

            var dialog = new ExperienceAEM.ColorPickerDialog();
            dialog.attach(config, $container, this.editorKernel);

            return dialog;
        }
    });

    //extend CUI toolkit impl to create instances of extended toolbar builder and dialog manager
    ExperienceAEM.ToolkitImpl = new Class({
        toString: "EAEMCuiToolbarBuilder",

        extend: CUI.rte.ui.cui.ToolkitImpl,

        createToolbarBuilder: function() {
            return new ExperienceAEM.CuiToolbarBuilder();
        },

        createDialogManager: function(editorKernel) {
            return new ExperienceAEM.DialogManager(editorKernel);
        }
    });

    CUI.rte.ui.ToolkitRegistry.register("cui", ExperienceAEM.ToolkitImpl);

    //the color picker plugin for touch ui
    ExperienceAEM.TouchUIColorPickerPlugin = new Class({
        toString: "TouchUIColorPickerPlugin",

        extend: CUI.rte.plugins.Plugin,

        pickerUI: null,

        getFeatures: function() {
            return [ ExperienceAEM.TCP_FEATURE ];
        },

        initializeUI: function(tbGenerator) {
            var plg = CUI.rte.plugins;

            //add the color picker
            if (this.isFeatureEnabled(ExperienceAEM.TCP_FEATURE)) {
                this.pickerUI = tbGenerator.createElement(ExperienceAEM.TCP_FEATURE, this, true, "Color Picker");
                tbGenerator.addElement("format", plg.Plugin.SORT_FORMAT, this.pickerUI, 140);
            }
        },

        //executes when user clicks on color picker button to open the picker dialog
        execute: function(id, value, envOptions) {
            var ek = this.editorKernel;
            var dm = ek.getDialogManager();

            if (dm.isShown(this.dialog)) {
                dm.hide(this.dialog);
                return;
            }

            var dialogConfig = {
                execute: function(value) {
                    ek.relayCmd(id, value);
                },
                parameters : {
                    "command": ExperienceAEM.TCP_UI_SETTING
                }
            };

            //create or get existing dialog
            this.dialog = dm.create(ExperienceAEM.TCP_DIALOG, dialogConfig);

            dm.prepareShow(this.dialog);

            dm.show(this.dialog);

            if(!this.dialog.colorPicker){
                //default colors if the colors are not configured for plugin in crx
                var colors = { "White" : "FFFFFF", "Yellow" : "FFFF00" };

                if(this.config.colors){
                    colors = this.config.colors;
                }

                var options = {
                    element : $('[data-rte-dialog="' + ExperienceAEM.TCP_DIALOG + '"] .coral-ColorPicker'),
                    config : {
                        colors: colors,
                        displayModes : {
                            "freestylePalette" : true,
                            "edit" : false
                        }
                    }
                };

                //create the picker
                this.dialog.colorPicker = new CUI.Colorpicker(options);
            }

            var context = envOptions.editContext;

            var selection = CUI.rte.Selection.createProcessingSelection(context);
            var tag = CUI.rte.Common.getTagInPath(context, selection.startNode, "span" );

            //get existing color to initialize picker
            var color = $(tag).css("color");

            if(color){
                this.dialog.colorPicker._setColor(color);
            }
        },

        updateState: function(selDef) {
            var hasColorPicker = this.editorKernel.queryState(ExperienceAEM.TCP_FEATURE, selDef);

            if (this.pickerUI != null) {
                this.pickerUI.setSelected(hasColorPicker);
            }
        }
    });

    CUI.rte.plugins.PluginRegistry.register(ExperienceAEM.TCP_FEATURE,
                                                ExperienceAEM.TouchUIColorPickerPlugin);

    //the command for making text colored
    ExperienceAEM.ColorPickerCmd = new Class({
        toString: "ColorPickerCmd",

        extend: CUI.rte.commands.Command,

        isCommand: function(cmdStr) {
            return (cmdStr.toLowerCase() == ExperienceAEM.TCP_FEATURE);
        },

        getProcessingOptions: function() {
            var cmd = CUI.rte.commands.Command;
            return cmd.PO_SELECTION | cmd.PO_BOOKMARK | cmd.PO_NODELIST;
        },

        _getTagObject: function(color) {
            return {
                "tag": "span",
                "attributes": {
                    "style" : "color: " + color
                }
            };
        },

        execute: function(execDef) {
            var selection = execDef.selection;

            if (!selection) {
                return;
            }

            var nodeList = execDef.nodeList;

            if (!nodeList) {
                return;
            }

            var common = CUI.rte.Common;
            var context = execDef.editContext;

            var tagObj = this._getTagObject(execDef.value);

            //if no color value passed, assume delete and remove color
            if(!execDef.value){
                nodeList.removeNodesByTag(execDef.editContext, tagObj.tag, undefined, true);
                return;
            }

            var tags = common.getTagInPath(context, selection.startNode, tagObj.tag);

            //remove existing color before adding new color
            if (tags != null) {
                nodeList.removeNodesByTag(execDef.editContext, tagObj.tag, undefined, true);
            }

            nodeList.surround(execDef.editContext, tagObj.tag, tagObj.attributes);
        },

        queryState: function(selectionDef) {
            var common = CUI.rte.Common;
            var context = selectionDef.editContext;

            var selection = selectionDef.selection;
            var tagObj = this._getTagObject();

            return (common.getTagInPath(context, selection.startNode, tagObj.tag, tagObj.attributes) != null);
        }
    });

    CUI.rte.commands.CommandRegistry.register(ExperienceAEM.TCP_FEATURE, ExperienceAEM.ColorPickerCmd);

    //returns the picker dialog html
    //Handlebars doesn't do anything useful here, but the framework expects a template
    var cpTemplate = function(){
        CUI.rte.Templates["dlg-" + ExperienceAEM.TCP_DIALOG] =
            Handlebars.compile('<div data-rte-dialog="' + ExperienceAEM.TCP_DIALOG + '" class="coral--dark coral-Popover coral-RichText-dialog">'
                + '<div class="coral-RichText-dialog-columnContainer">'
                    + '<div class="coral-RichText-dialog-column">'
                    +   '<label class="coral-Form-fieldlabel">Select color </label>'
                    + '</div>'
                    + '<div class="coral-RichText-dialog-column">'
                        + '<span  class="coral-Form-field coral-ColorPicker">'
                            + '<button class="coral-ColorPicker-button coral-MinimalButton" type="button"></button>'
                        + '</span>'
                    + '</div>'
                    + '<div class="coral-RichText-dialog-column">'
                        + '<button data-type="apply" class="coral-RichText-dialogButton coral-Icon coral-Icon--check coral-Icon--sizeS coral-RichText--white coral-Button--primary"></button>'
                    + '</div>'
                    + '<div class="coral-RichText-dialog-column">'
                        + '<button data-type="cancel" class="coral-RichText-dialogButton coral-Icon coral-Icon--close coral-Icon--sizeS coral-RichText--white"></button>'
                    + '</div>'
                    + '<div class="coral-RichText-dialog-column">'
                        + '<button data-type="delete" class="coral-RichText-dialogButton coral-Icon coral-Icon--delete coral-Icon--sizeS coral-RichText--white coral-Button--warning"></button>'
                    + '</div>'
                + '</div>'
            + '</div>');
    };

    cpTemplate();
})();


7) Add any text component with RichText editor and in the rtePlugins path of dialog add touchuicolorpicker node to enable color picker plugin

AEM 6 - Classic UI Restrict User from Creating Pages with Same Title

Goal


Extend Classic UI Create Page Dialog to restrict user from creating pages with same title in a path. Create button is disabled when user opens the dialog and enabled only when user enters a unique title

Demo | Package Install



Solution


1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/classic-ui-no-page-duplication

2) Create node /apps/classic-ui-no-page-duplication/clientlib of type cq:ClientLibraryFolder and add a String property categories with value cq.widgets

3) Create file (nt:file) /apps/classic-ui-no-page-duplication/clientlib/js.txt and add

                       check-page-name.js

4) Create file (nt:file) /apps/classic-ui-no-page-duplication/clientlib/check-page-name.js and add the following code.

(function(){
    //the original create page dialog fn
    var cqCreatePageDialog = CQ.wcm.Page.getCreatePageDialog;
 
    //override ootb function
    CQ.wcm.Page.getCreatePageDialog = function(parentPath){
        //create dialog by executing the product function
        var dialog = cqCreatePageDialog(parentPath);

        try{
            //disable create until page title gets validated
            var createButton = dialog.buttons[0];
            createButton.setDisabled(true);

            //make necessary UI changes to the dialog created above
            var panel = dialog.findBy(function(comp){
                return comp["jcr:primaryType"] == "cq:Panel";
            }, dialog);

            if(!panel || !panel.length){
                return;
            }

            panel = panel[0];

            //get title field
            var titleField = panel.findBy(function(comp){
                return comp["fieldLabel"] == "Title";
            }, panel);

            if(!titleField || !titleField.length){
                return;
            }

            titleField = titleField[0];

            titleField.on('change', function(t, nvalue){
                //when user enters title, search CRX if a title with same wording exists
                $.ajax({
                    type: "GET",
                    url: "/bin/querybuilder.json",
                    data: {
                        "path": parentPath,
                        "0_property": "jcr:title",
                        "0_property.value": nvalue
                    }
                }).done(function(data){
                    if(data && data.hits && data.hits.length > 0){
                        CQ.Ext.Msg.alert("Error", "Page " + nvalue + " exists in path " + parentPath);
                        return;
                    }

                    //not a duplicate, enable create button
                    createButton.setDisabled(false);
                })
            })
        }catch(err){
            console.log("Error executing CQ.wcm.Page.getCreatePageDialog override");
        }

        return dialog;
    }
})();


AEM 6 SP1 - Touch UI Rich Text Editor Plugin To Upper Case

Goal


Create a Touch UI RTE (Rich Text Editor) plugin for converting text to upper case. Here we use css style attribute text-transform:uppercase to convert any selected text in editor to upper case

Demo shows dialog of foundation text component modified to add the plugin config. This is just for demonstration only (on Geometrixx pages), ideally the foundation components should never be altered...

A Sample classic UI RTE Extension is available here

For a dialog RTE extension on AEM 62 - check this post

Demo | Package Install


Full Screen View




Inline View


Solution


1) Login to CRXDE Lite (http://localhost:4502/crx/de) and create folder /apps/rte-touch-ui-to-upper-case

2) Create node /apps/rte-touch-ui-to-upper-case/clientlib of type cq:ClientLibraryFolder and add a String property categories with value rte.coralui2

3) Create file (nt:file) /apps/rte-touch-ui-to-upper-case/clientlib/css.txt and add

                       to-upper.css

4) Create file (nt:file) /apps/rte-touch-ui-to-upper-case/clientlib/to-upper.css and add the following code. Content is available in font-family: AdobeIcons

.eam-touchui-to-upper-case::before {
    content: "\f294";
}

5) Create file (nt:file) /apps/rte-touch-ui-to-upper-case/clientlib/js.txt and add

                       to-upper.js

6) Create file (nt:file) /apps/rte-touch-ui-to-upper-case/clientlib/to-upper.js and add the following code.

(function(){
    var ExperienceAEM = {
        TUC_UI_SETTING: "touchuitouppercase#touchuitouppercase",
        TUC_FEATURE: "touchuitouppercase"
    };

    //extend toolbar builder to register upper case styles
    ExperienceAEM.CuiToolbarBuilder = new Class({
        toString: "EAEMCuiToolbarBuilder",

        extend: CUI.rte.ui.cui.CuiToolbarBuilder,

        //add uppercase toolbar icon to the existing set
        _getUISettings: function(options) {
            var uiSettings = this.superClass._getUISettings(options);

            //inline toolbar
            var items = uiSettings["inline"]["popovers"]["format"].items;

            if(items.indexOf(ExperienceAEM.TUC_UI_SETTING) == -1){
                items.push(ExperienceAEM.TUC_UI_SETTING);
            }

            //fullscreen toolbar
            items = uiSettings["fullscreen"]["toolbar"];

            if(items.indexOf(ExperienceAEM.TUC_UI_SETTING) == -1){
                items.splice(3, 0, ExperienceAEM.TUC_UI_SETTING);
            }

            if(!this._getClassesForCommand(ExperienceAEM.TUC_UI_SETTING)){
                this.registerAdditionalClasses(ExperienceAEM.TUC_UI_SETTING, "coral-Icon eam-touchui-to-upper-case");
            }

            return uiSettings;
        }
    });

    ExperienceAEM.ToolkitImpl = new Class({
        toString: "EAEMToolkitImpl",

        extend: CUI.rte.ui.cui.ToolkitImpl,

        createToolbarBuilder: function() {
            return new ExperienceAEM.CuiToolbarBuilder();
        }
    });

    CUI.rte.ui.ToolkitRegistry.register("cui", ExperienceAEM.ToolkitImpl);

    ExperienceAEM.TouchUIUpperCasePlugin = new Class({
        toString: "TouchUIUpperCasePlugin",

        extend: CUI.rte.plugins.Plugin,

        pickerUI: null,

        getFeatures: function() {
            return [ ExperienceAEM.TUC_FEATURE ];
        },

        initializeUI: function(tbGenerator) {
            var plg = CUI.rte.plugins;

            if (this.isFeatureEnabled(ExperienceAEM.TUC_FEATURE)) {
                this.pickerUI = tbGenerator.createElement(ExperienceAEM.TUC_FEATURE, this, true, "To Upper Case");
                tbGenerator.addElement("format", plg.Plugin.SORT_FORMAT, this.pickerUI, 120);
            }
        },

        execute: function(id) {
            this.editorKernel.relayCmd(id);
        },

        //to mark the uppercase icon selected/deselected
        updateState: function(selDef) {
            var hasUC = this.editorKernel.queryState(ExperienceAEM.TUC_FEATURE, selDef);

            if (this.pickerUI != null) {
                this.pickerUI.setSelected(hasUC);
            }
        },

        notifyPluginConfig: function(pluginConfig) {
            pluginConfig = pluginConfig || { };

            var defaults = {
                "tooltips": {
                    "touchuitouppercase": {
                        "title": "To Upper Case",
                        "text": "To Upper Case"
                    }
                }
            };

            CUI.rte.Utils.applyDefaults(pluginConfig, defaults);

            this.config = pluginConfig;
        }
    });

    CUI.rte.plugins.PluginRegistry.register(ExperienceAEM.TUC_FEATURE,ExperienceAEM.TouchUIUpperCasePlugin);

    ExperienceAEM.UpperCaseCmd = new Class({
        toString: "UpperCaseCmd",

        extend: CUI.rte.commands.Command,

        isCommand: function(cmdStr) {
            return (cmdStr.toLowerCase() == ExperienceAEM.TUC_FEATURE);
        },

        getProcessingOptions: function() {
            var cmd = CUI.rte.commands.Command;
            return cmd.PO_SELECTION | cmd.PO_BOOKMARK | cmd.PO_NODELIST;
        },

        _getTagObject: function() {
            return {
                "tag": "span",
                "attributes": {
                    "style" : "text-transform:uppercase"
                }
            };
        },

        execute: function(execDef) {
            var selection = execDef.selection;

            if (!selection) {
                return;
            }

            var nodeList = execDef.nodeList;

            if (!nodeList) {
                return;
            }

            var common = CUI.rte.Common;
            var context = execDef.editContext;

            var tagObj = this._getTagObject();

            var tags = common.getTagInPath(context, selection.startNode, tagObj.tag, tagObj.attributes);

            if (tags == null) {
                nodeList.surround(execDef.editContext, tagObj.tag, tagObj.attributes);
            } else {
                nodeList.removeNodesByTag(execDef.editContext, tagObj.tag, tagObj.attributes, true);
            }
        },

        queryState: function(selectionDef, cmd) {
            var common = CUI.rte.Common;
            var context = selectionDef.editContext;

            var selection = selectionDef.selection;
            var tagObj = this._getTagObject();

            return (common.getTagInPath(context, selection.startNode, tagObj.tag, tagObj.attributes) != null);
        }
    });

    CUI.rte.commands.CommandRegistry.register(ExperienceAEM.TUC_FEATURE, ExperienceAEM.UpperCaseCmd);
})();

AEM 6 SP1 - Rendition Maker Sample

Goal


A Sample OSGI Servlet demonstrating Rendition Maker. Creates a 250 X 250 thumbnail rendition

Source Code | Package Install

Install the OSGI Servlet apps.experienceaem.rms.SampleRenditionMaker and access /bin/experienceaem/rms with path ( eg. http://localhost:4502/bin/experience-aem/rms?path=/content/dam/Product/Desert.jpg)

Sample generated - http://localhost:4502/content/dam/Product/Desert.jpg/jcr:content/renditions/cq5dam.thumbnail.250.250.png


Servlet Source


package apps.experienceaem.rms;

import com.day.cq.dam.api.Asset;
import com.day.cq.dam.api.Rendition;
import com.day.cq.dam.api.renditions.RenditionMaker;
import com.day.cq.dam.api.renditions.RenditionTemplate;
import com.day.cq.dam.api.thumbnail.ThumbnailConfig;
import com.day.cq.dam.commons.thumbnail.ThumbnailConfigImpl;
import org.apache.felix.scr.annotations.*;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.commons.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import javax.jcr.Node;
import javax.jcr.Session;
import javax.servlet.ServletException;
import java.io.IOException;
import java.util.List;

@Component(metatype = true, label = "Experience AEM Sample Rendition Maker")
@Service
@Properties({
        @Property(name = "sling.servlet.methods", value = {"GET"}, propertyPrivate = true),
        @Property(name = "sling.servlet.paths", value = "/bin/experience-aem/rms", propertyPrivate = true),
        @Property(name = "sling.servlet.extensions", value = "json", propertyPrivate = true)})
public class SampleRenditionMaker extends SlingAllMethodsServlet {
    private static final Logger log = LoggerFactory.getLogger(SampleRenditionMaker.class);

    @Reference
    private RenditionMaker renditionMaker;

    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
                            throws ServletException,IOException {
        JSONObject json = new JSONObject();

        try {
            ResourceResolver resolver = request.getResourceResolver();
            String path = request.getParameter("path");

            if (path == null) {
                json.put("error", "Empty path");
                return;
            }

            Session session = resolver.adaptTo(Session.class);
            Asset asset = resolver.getResource(path).adaptTo(Asset.class);

            RenditionTemplate[] templates = createRenditionTemplates(asset);

            List<Rendition> renditionList = renditionMaker.generateRenditions(asset, templates);

            session.save();

            json.put("success", "Created - " + renditionList.get(0).getPath());

            json.write(response.getWriter());
        } catch (Exception e) {
            log.error("Error processing request", e);
        }
    }

    private RenditionTemplate[] createRenditionTemplates(Asset asset) {
        ThumbnailConfig[] thumbnails = new ThumbnailConfig[1];
        thumbnails[0] = new ThumbnailConfigImpl(250,250,false);

        RenditionTemplate[] templates = new RenditionTemplate[thumbnails.length];

        for (int i = 0; i < thumbnails.length; i++) {
            ThumbnailConfig thumb = thumbnails[i];

            templates[i] = renditionMaker.createThumbnailTemplate(asset,thumb.getWidth(),
                                thumb.getHeight(),thumb.doCenter());
        }

        return templates;
    }
}

AEM 6 - Search and Download DAM Assets in Adobe CC Products

Goal


This post is on developing a Adobe CC HTML Extension to login to AEM 6, search and download Adobe CC assets (eg. Adobe Illustrator 18.1 files) uploaded to AEM DAM from CC products like Illustrator, InDesign, Photoshop...

CC HTML Extensions ( short guide and samples ) run on Common Extensibility Platform (CEP) of Adobe Illustrator 18.1, Adobe InDesign 10.1 etc. (the best part is, if you are not/little interacting with the host in extensions, without any changes they can run on various Adobe CC products). For example (though i've not tried) there are very good changes the sample extension developed in this post will run with minor changes (like adding an entry in CSXS/manifest.xml) in PhotoShop...

For downloading AEM assets (images, content fragments) and place them on InDesign pages check this post

Demo | AI 18.1 IDSN 10.1 CC HTML Extension | Extension Source  |  AEM 6 Package Install

I just put together the pieces, thanks to my fantastic adobe colleagues and internet for quick code snippets


Login panel in Illustrator ( click Window -> Extensions -> Search AEM )





AEM Asset Search in Illustrator





Login Panel in InDesign





Asset Search in InDesign





Solution


AEM CRX Updates

1) Login to CRXDE Lite (http://localhost:4502/crx/de), create folder config.author and node org.apache.sling.security.impl.ReferrerFilter of type sling:OsgiConfig and set the allow.empty property to true (Package Install). This setting allows the CEP panel (explained in next steps) to post credentials successfully to AEM. It's an observation that Chromium Embedded of CEP doesn't send referer header with requests (a bug in CEP?). To circumvent the problem we set allow.empty to true (it may be a security issue in publish environments; this CEP extension connects to and downloads assets from author instance, so set the configuration in config.author folder)





CC HTML Extension

1) Create the following folder structure (Download source). This folder structure is installed as extension. The lib folder (containing helper files for creating the extension) is excluded from final executable created

                           search-aem
                                   css
                                         style.css
                                   CSXS
                                         manifest.xml
                                   html
                                         search-aem.html
                                   js
                                          CSInterface-5.2.js
                                          login-search.js
                                   .debug
                                   build.xml


2)  Use ant for assembling the extension for dev and distribution purposes. Ant targets to copy files to extensions folder and create installer.


Develop & Test
         
              a. Create CEP/extensions folder in user's home if it doesn't exist. On my windows it's C:\Users\nalabotu\AppData\Roaming\Adobe\CEP\extensions and MAC /Library/Application Support/Adobe/CEP/extensions

              b. Set PlayerDebugMode to  ( for debugging extensions the flag PlayerDebugMode needs to be set to 1)

                    On Windows, Open registry (Run -> regedit), HKEY_CURRENT_USER\Software\Adobe\CSXS.5



                    On local MAC (/Users/nalabotu/Library/Preferences/com.adobe.CSXS.5.plist)



              c. Create a .debug file in extension root. Add following xml; extension debugger will be available on http://localhost:8098/ when the extension panel is open in Illustrator 18.1. Extension id should be the same id added in CSXS/manifest.xml (next steps)

<?xml version="1.0" encoding="UTF-8"?>
<ExtensionList>
    <Extension Id="com.experience.aem.cep.search">
        <HostList>
           <Host Name="ILST" Port="8098"/>
        </HostList>
    </Extension>
</ExtensionList>
         
              d. For development purposes we can use the following ant target (in build.xml) to copy files over to user's extensions folder eg. C:\Users\nalabotu\AppData\Roaming\Adobe\CEP\extensions

    <target name="copy">
        <delete dir="${live.extensions.dir}" />

        <mkdir dir="${live.extensions.dir}"/>

        <copy todir="${live.extensions.dir}" failonerror="false">
            <fileset dir="${basedir}">
                <exclude name="*.xml"/>
                <exclude name="*.iml"/>
                <exclude name="lib/**"/>
                <exclude name="temp/**"/>
                <exclude name="docs/**"/>
            </fileset>
        </copy>
    </target>

               e. Executing ant target copy, copies the extension files to extensions folder (eg. C:\Users\nalabotu\AppData\Roaming\Adobe\CEP\extensions)

               f. To debug the extension, open CC product (here Adobe Illustrator 18.1), make sure extension panel (Search AEM is open), open chrome browser and access http://localhost:8098/. Check log files, for example on my local C:\Users\nalabotu\AppData\Local\Temp\csxs5-IDSN.log, C:\Users\nalabotu\AppData\Local\Temp\csxs5-ILST.log, C:\Users\nalabotu\AppData\Local\Temp\cef_debug.log; For MAC the log files are in /Users/<user>/Library/Logs/CSXS





Distribute & Install

                a. To distribute the extension for installation on user's CC products, create a ZXP.

                b. ZXP should be CA (Certificate authority ) signed or self signed.

                c. To create a self signed key and certificate in pkcs12 format, use the following openssl commands (A sample certificate experience-aem-cep.p12 is available in
                    extension source, lib folder, password is experience-aem)

                    openssl req -x509 -days 365 -newkey rsa:2048 -keyout experience-aem-cep-key.pem -out experience-aem-cep-cert.pem

                    openssl pkcs12 -export -in  experience-aem-cep-cert.pem -inkey experience-aem-cep-key.pem -out experience-aem-cep.p12

                d. A signing toolkit ZXPSignCmd (available in extension source or can be downloaded at adobe labs ). It's different on MAC (the one available in source is Windows executable)

                e. Use following ant target zxp (in build.xml) to create the extension installer search-aem.zxp in extension source/temp folder

    <!--
    Use this target for creating zxp, installed using Adobe Extension Manager
    The ZXPSignCmd library is different for MAC, the following targets use windows library
    -->
    <target name="zxp" if="isWindows">
        <delete dir="${basedir}/temp" />

        <mkdir dir="${basedir}/temp" />

        <copy todir="${basedir}/temp" failonerror="false">
            <fileset dir="${basedir}">
                <exclude name="*.xml"/>
                <exclude name="*.iml"/>
                <exclude name="lib/**"/>
                <exclude name="temp/**"/>
                <exclude name="docs/**"/>
                <exclude name=".debug"/>
            </fileset>
        </copy>

        <exec executable="${basedir}/lib/ZXPSignCmd">
            <arg line="-sign ${basedir}/temp" />
            <arg value="${basedir}/temp/search-aem.zxp" />
            <arg value="${basedir}/lib/experience-aem-cep.p12" />
            <arg value="experience-aem" />  <!-- cert password -->
        </exec>
    </target>

                   f.  To install the extension, use Adobe Extension Manager CC. Click Install on top right, browse to zxp folder and select search-aem.zxp. If the certificate used is not
                       CA signed (ie. a self signed) Extension Manager alerts with warning...




                       g. The installed extension. Open Adobe Illustrator 18.1 (Window -> Extensions) or InDesign 10.1 (Window -> Extensions) and you should see the Search AEM menu item






3) The most important part of the extension is CSXS/manifest.xml. The following xml fragment of manifest says this extension runs on Illustrator 18.1 and InDesign 10.1

        <HostList>
            <Host Name="ILST" Version="18.1"/>
            <Host Name="IDSN" Version="10.1"/>
            <!-- Add other CC products here -->
        </HostList>

4) The extension declaration with id com.experience.aem.cep.search (provided in .debug file for debugging in browser) <MainPath>./html/search-aem.html</MainPath> instructs Illustrator to load the html file html/search-aem.html when extension is opened by clicking Window -> Extensions -> Search AEM

        <Extension Id="com.experience.aem.cep.search">
            <DispatchInfo>
                <Resources>
                    <MainPath>./html/search-aem.html</MainPath>
                </Resources>
                <Lifecycle>
                    <AutoVisible>true</AutoVisible>
                </Lifecycle>
                <UI>
                    <Type>Panel</Type>
                    <Menu>Search AEM</Menu>
                    <Geometry>
                        <Size>
                            <Height>400</Height>
                            <Width>350</Width>
                        </Size>
                    </Geometry>
                </UI>
            </DispatchInfo>
        </Extension>

4) For AEM developers, developing the actual extension should be pretty easy, its standard html, css, javascript

5) The following code in search-aem.html provides extension view. It loads angular, underscore from cdns. CSInterface-5.2.js is a helper CEP file to interact with host (Illustrator or InDesign) to run native code, for example in later steps we code a simple script to open downloaded file )

<!DOCTYPE html>
<html lang="en" ng-app="SearchAEM">

<head>
    <meta charset="utf-8">

    <title>Login Search AEM</title>

    <link rel="stylesheet" href="../css/style.css">
</head>

<body>
<div ng-controller="pc">
    <div class="sign-in" ng-show="showLogin">
        <div>
            <h3>DIGITAL ASSET MANAGEMENT</h3>
        </div>
        <div>
            <label>User Name</label>
            <input type="text" ng-model="j_username" ng-enter="login()"/>
        </div>
        <div>
            <label>Password</label>
            <input type="password" ng-model="j_password" ng-enter="login()"/>
        </div>
        <div>
            <label>DAM Host</label>
            <input type="text" value="{{damHost}}" ng-model="damHost" ng-enter="login()"/>
        </div>

        <button type="button" ng-click="login()">Sign In</button>
    </div>

    <div ng-show="!showLogin">
        <div class="top-left">
            <input type="text" placeholder="Enter search text" ng-model="term" ng-enter="search()"/>
        </div>
        <div class="top-right">
            <button type="button" ng-click="search()">Search</button>
        </div>
        <div class="results">
            <div class="result-block" ng-repeat="result in results" ng-click="download(result)">
                <div>
                    <img ng-src="{{result.imgPath}}"/>
                </div>
                <div>
                    {{result.name}}
                </div>
            </div>
        </div>
    </div>
</div>

<script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js"></script>
<script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.3.9/angular.js"></script>
<script src="../js/CSInterface-5.2.js"></script>
<script src="../js/login-search.js"></script>
</body>
</html>

6) The js/login-search.js provides necessary login/search/download from AEM functionality. Discussing Angular or Underscore logic is beyond the scope of this post; JS frameworks are not mandatory for developing CC HTML extensions, if you know HTML, CSS, Javascript that's good enough

'use strict';

(function () {
    var underscore = angular.module('underscore', []);

    underscore.factory('_', function () {
        return window._;
    });

    var cep = angular.module('cep', []);

    cep.factory('cep', ['$window', function ($window) {
        return $window.cep;
    }]);

    cep.service('csi', CSInterface);

    var aem = angular.module('aem', ['underscore', 'cep']);

    aem.service('login', [ '$http' , '_', function ($http, _) {
        return {
            login: function (username, password, damHost) {
                var jSecurityCheck = damHost + "/libs/granite/core/content/login.html/j_security_check";

                var data = {
                    j_username: username,
                    j_password: password,
                    j_validate: true
                };

                return $http.post(jSecurityCheck, data, {
                    headers: {
                        'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
                    },
                    transformRequest: function(obj) {
                        var params = [];

                        angular.forEach(obj, function(value, key){
                            params.push(encodeURIComponent(key) + "=" + encodeURIComponent(value));
                        });

                        return params.join("&");
                    }
                });
            }
        }
    }]);

    aem.factory('search', [ '_', '$http', function (_, $http) {
        return function (defaults) {
            this.aem = "http://localhost:4502";
            this.params = _.extend({}, defaults);
            this.numPredicates = 0;

            this.host = function(aem){
                this.aem = aem;
                return this;
            };

            this.fullText = function (value) {
                if (!value) {
                    return this;
                }

                this.params[this.numPredicates + '_fulltext'] = value;
                this.numPredicates++;

                return this;
            };

            this.http = function(){
                var builder = this;

                return $http({
                    method: 'GET',
                    url: builder.aem + "/bin/querybuilder.json",
                    params: builder.params
                });
            }
        }
    }]);

    var app = angular.module('SearchAEM', ['aem']);

    app.directive('ngEnter', function () {
        return function(scope, element, attrs) {
            element.bind("keydown keypress", function(event) {
                if (event.which === 13) {
                    scope.$apply(function() {
                        scope.$eval(attrs.ngEnter);
                    });

                    event.preventDefault();
                }
            });
        };
    });

    app.controller('pc', [ '$scope', 'login', 'search', '$http', 'csi', 'cep',
        function ($scope, login, search, $http, csi, cep) {
            $scope.damHost = "localhost:4502";
            $scope.showLogin = true;

            $scope.login = function () {
                if (!$scope.j_username || !$scope.j_password || !$scope.damHost) {
                    alert("Enter credentials");
                    return;
                }

                $scope.damHost = $scope.damHost.trim();

                if ($scope.damHost.indexOf("http://") == -1) {
                    $scope.damHost = "http://" + $scope.damHost;
                }

                login.login($scope.j_username, $scope.j_password, $scope.damHost)
                    .success(function (data) {
                        $scope.showLogin = false;
                    })
                    .error(function () {
                        alert("Trouble logging-in, Invalid Credentials?")
                    })
            };

            var searchDefaults = {
                'path': "/content/dam",
                'type': 'dam:Asset',
                'orderby': '@jcr:content/jcr:lastModified',
                'orderby.sort': 'desc',
                'p.hits': 'full',
                'p.nodedepth': 2,
                'p.limit': 25,
                'p.offset': 0
            };

            $scope.search = function () {
                if (!$scope.term) {
                    alert("Enter search term");
                    return;
                }

                $scope.results = [];

                var mapHit = function(hit) {
                    var result;

                    result = {};

                    result.name = hit["jcr:path"].substring(hit["jcr:path"].lastIndexOf("/") + 1);
                    result.url = $scope.damHost + hit["jcr:path"];
                    result.imgPath = $scope.damHost + hit["jcr:path"] + "/jcr:content/renditions/cq5dam.thumbnail.140.100.png";

                    return result;
                };

                new search(searchDefaults).host($scope.damHost)
                        .fullText($scope.term)
                        .http()
                        .then(function(resp) {
                    $scope.results = _.compact(_.map(resp.data.hits, mapHit));
                });
            };

            $scope.download = function(result){
                $http.get(result.url, {
                    responseType: "blob"
                }).success(function(data) {
                    var reader = new FileReader();

                    reader.onload = function() {
                        var filePath = csi.getSystemPath(SystemPath.MY_DOCUMENTS)
                                            + "/" + result.name;
                        cep.fs.writeFile(filePath, reader.result.split(',')[1], cep.encoding.Base64);

                        csi.evalScript("(function(){app.open(new File('" + filePath + "'));})();", function(){
                            alert("File " + result.name + " downloaded as " + filePath)
                        });
                    };

                    reader.readAsDataURL(data);
                }).error(function() {
                    alert("Error downloading file");
                });
            };
    }]);
}());

7) The following script at #183, executes app.open call to open the downloaded file in Illustrator or InDesign. It's a simple script, provided inline to the evalScript() function; for developing sleek panels with complex extend script logic (Extendscript is similar to javascript, used to code CC extensions) you can use <ScriptPath></ScriptPath> in CSXS/manifest.xml (not explained in this post). app is a global variable available in host (Illustrator or InDesign)

                        csi.evalScript("(function(){app.open(new File('" + filePath + "'));})();", function(){
                            alert("File " + result.name + " downloaded as " + filePath)
                        });

8) A note on using JQuery in CC HTML Extensions. If you get error $ is undefined when executing jquery selectors, add window.module=undefined in html. I am not aware of the details behind NodeJS and JQuery ($) not able to co-exist, but having window.module=undefined makes NodeJS unavailable in extension panel and a developer can code using JQuery

    <script type="text/javascript">
        window.module = undefined;
    </script>