Posts tagged "cq5"

October 4, 2013

Custom Component: Manual List Widget

Requirement: The users want the ability to manually choose a list of of items from a source list of items.

Example: A list of all the articles in the system is the source list and the users want to select the most important ones to be listed on the home page of a site.

To achieve this, I created a custom ExtJs widget.

  • The users are able to double click on an item on the left (source) list to move the said item to the right list
  • They can manually order the items on the right list via drag and drop.
  • They can double click the item on the right list to remove it from the list
  • They are also able to pass tags to limit the results (your custom servlet that returns the JSON object to be consumed by the widget will need to handle this use case)

Below are the steps to use this widget in your implementation.

1. Paste the widget code from below into a JS file in your CQ implementation.

//Create a new class based on existing CompositeField
CQ.form.GridtoGrid = CQ.Ext.extend(CQ.CustomContentPanel, {
    secondGrid: null,
    firstGrid: null,
    columnModel: null,
    reader: null,
    layout: 'fit',
    constructor : function(config){

        var dataSrcUrl;

        if( config.dataSrcUrl != null ){
            dataSrcUrl = config.dataSrcUrl;
        }

        var jsonReaderConfig;
        if( config.jsonReaderConfig != null ){
            jsonReaderConfig = config.jsonReaderConfig;
        }

        var sortDirection;
        if( config.sortDirection != null ){
            sortDirection = config.sortDirection;
        }

        var sortField;
        if( config.sortField != null ){
            sortField = config.sortField;
        }

        var columnModelConfig;
        if( config.columnModelConfig != null ){
            columnModelConfig = config.columnModelConfig;
        }

        console.log( dataSrcUrl );
        console.log( sortDirection );
        console.log( sortField );
        console.log( CQ.Ext.util.JSON.decode( jsonReaderConfig ) );
        console.log( CQ.Ext.util.JSON.decode( columnModelConfig ) );

        var proxy = new CQ.Ext.data.HttpProxy( {url:  dataSrcUrl} );

        reader = new CQ.Ext.data.JsonReader( {}, CQ.Ext.util.JSON.decode( jsonReaderConfig ) );

        var sourceStore = new CQ.Ext.data.Store(
            {
                proxy: proxy,
                sortInfo: {
                    field: sortField,
                    direction: sortDirection
                },
                reader: reader
            }
        );
        sourceStore.load();

        columnModel = new CQ.Ext.grid.ColumnModel( CQ.Ext.util.JSON.decode( columnModelConfig ) );

        firstGrid = new CQ.Ext.grid.GridPanel({
            listeners: {
                rowdblclick: function (grid, index){

                    //On DoubleClick, get the record from the sourceStore using the index.
                    var moveRecord = firstGrid.getStore().getAt( index );

                    //Add the record to the destinationStore.
                    secondGrid.getStore().add( moveRecord );
                }
            },
            store            : sourceStore,
            name             : 'sourceGrid',
            colModel          : columnModel,
            stripeRows       : true,
            title            : 'Latest Articles',
            margins          : '0 2 0 0',
            x: 0,
            y:100,
            height: 500

        });

        //destination store
        var destinationStore = new CQ.Ext.data.Store({
            reader: reader
        });

        //destination grid
        secondGrid = new CQ.Ext.grid.GridPanel({
            listeners: {
                rowdblclick: function (grid, index){
                    //On DoubleClick, get the record from the sourceStore using the index.
                    var moveRecord = secondGrid.getStore().getAt( index );

                    //Remove the record from the destinationStore.
                    secondGrid.getStore().remove( moveRecord );
                }
            },
            name: 'destinationGrid',
            store: destinationStore,
            colModel: columnModel,
            stripeRows       : true,
            title            : 'Displayed Articles',
            enableDragDrop: true,
            hideHeaders: true,
            ddGroup: 'ddGroup',
            margins          : '0 0 0 3',
            x: 450,
            y: 100,
            height: 500
        });

        //Set the dropZone for the second grid so that users can manually re-order the list.
        secondGrid.on('render', function() {

            secondGrid.dropZone = new CQ.Ext.dd.DropZone(secondGrid.getView().scroller, {
                ddGroup: 'ddGroup',

                //If the mouse is over a grid row, return that node. This is
                //provided as the "target" parameter in all "onNodeXXXX" node event handling functions
                getTargetFromEvent: function(e) {
                    return e.getTarget(secondGrid.getView().rowSelector);
                },

                //On entry into a target node, highlight that node.
                onNodeEnter : function(target, dd, e, data){
                    CQ.Ext.fly(target).addClass('my-row-highlight-class');
                },

                //On exit from a target node, unhighlight that node.
                onNodeOut : function(target, dd, e, data){
                    CQ.Ext.fly(target).removeClass('my-row-highlight-class');
                },

                //While over a target node, return the default drop allowed class which
                //places a "tick" icon into the drag proxy.
                onNodeOver : function(target, dd, e, data){
                    return CQ.Ext.dd.DropZone.prototype.dropAllowed;
                },

                //On NodeDrop event
                onNodeDrop : function(target, dd, e, data){

                    var targetRowIndex = secondGrid.getView().findRowIndex(target);
                    var sourceRowIndex = data.rowIndex;

                    if( targetRowIndex == false ){
                        return false;
                    }

                    if( targetRowIndex == sourceRowIndex ){
                        return false;
                    }

                    var ds = secondGrid.getStore();

                    var sourceRecord = ds.getAt(sourceRowIndex);

                    ds.insert( targetRowIndex, sourceRecord );

                    //Reconfigure the destination list to new store.
                    secondGrid.reconfigure ( ds, columnModel );

                    return true;
                }
            });
        })

        //Tag Inputbox
        var tagInputField = new CQ.tagging.TagInputField({
            listeners:{
                addtag: function( inputField, tag ){

                    var proxyUrl = firstGrid.getStore().proxy.url;
                    var tagId = tag.tagID;

                    var tagsIndex = proxyUrl.indexOf( '?tags=' );

                    if( tagsIndex > -1 ){
                        proxyUrl = proxyUrl + "," + tagId;
                    } else {
                        proxyUrl = proxyUrl + "?tags=" + tagId;
                    }
                    //alert( "Add Tag: " + proxyUrl );
                    var updatedSourceStore = new CQ.Ext.data.Store({
                        url: proxyUrl,
                        reader: reader
                    });

                    updatedSourceStore.load();
                    firstGrid.reconfigure( updatedSourceStore, columnModel );
                },
                removetag: function( inputField, tag ){
                    var proxyUrl = firstGrid.getStore().proxy.url;
                    var tagId = tag.tagID;

                    var splitUrl = new Array();
                    splitUrl = proxyUrl.split( '?tags=' );

                    //alert( "Remove Tag 1 : " + proxyUrl );

                    if( splitUrl[1].indexOf( "," + tagId ) > -1 ){
                        splitUrl[1] = splitUrl[1].replace( "," + tagId, "" );
                    } else {
                        splitUrl[1] = splitUrl[1].replace( tagId , "" );
                    }

                    if( splitUrl[1].indexOf( ',' ) == 0 ){
                        splitUrl[1] = splitUrl[1].substring(1);
                    }

                    if( splitUrl[1] != null && splitUrl[1].length > 0 ){
                        proxyUrl = splitUrl[0] + '?tags=' + splitUrl[1];
                    } else {
                        proxyUrl = splitUrl[0];
                    }
                    //alert( "Remove Tag 2 : " + proxyUrl );

                    var updatedSourceStore = new CQ.Ext.data.Store({
                        url: proxyUrl,
                        reader: reader
                    });

                    updatedSourceStore.load();
                    firstGrid.reconfigure( updatedSourceStore, columnModel );
                }

            },
            title: 'Tag Properties',
            name: './tags',
            x: 250,
            y: 0,
            width: 400
        });
        CQ.form.GridtoGrid.superclass.constructor.call(this, config);

        this.selectionForm = new CQ.Ext.Panel({
            name         : this.name,
            width        : 'auto',
            height       : 600,
            border       : false,
            layout       : {
                type: 'absolute',
                padding: 0
            },
            items        : [
                tagInputField,
                firstGrid,
                secondGrid
            ]
        });
        this.add(this.selectionForm);

    },
    submitPanel: function() {

        var store = secondGrid.getStore();
        var storeData = store.data;
        var storeFields = store.fields;
        var params = {};

        if( storeData.length > 0 ){
            for(var i = 0; i < storeData.length; i++) {
                for(var j = 0; j < storeFields.length; j++){
                    var name = storeFields.item(j).name;
                    if (!params[name]) {
                        params[name] = new Array();
                    }

                    if( name == 'headline' ){
                        var headline = storeData.get(i).get(name);
                        if( headline.indexOf ( '_' ) == headline.length - 1 ){
                            params[name] = params[name].concat( storeData.get(i).get(name));
                        } else {
                            params[name] = params[name].concat( storeData.get(i).get(name) + "_");
                        }
                    } else {
                        params[name] = params[name].concat( storeData.get(i).get(name));
                    }
                }
            }
        }

        var serverResponse = CQ.utils.HTTP.post(this.urlPost, null, params, this);
        return CQ.utils.HTTP.isOk(serverResponse);
    },
    loadContent: function() {

        loadStore = null;

        if (!this.content) {
            var url = CQ.HTTP.externalize(this.urlGet);
            loadStore = new CQ.data.SlingStore({
                url: url+'.infinity.json'
            });
        } else if (this.content instanceof CQ.Ext.data.Store) {
            loadStore = this.content;
        }

        loadStore.load({
            callback: this.processRecords,
            scope: this
        });

    },
    processRecords: function(){

        var tagString = loadStore.getAt(0).get('tags');
        var sourceProxyUrl = firstGrid.getStore().proxy.url;

        if( tagString != undefined && sourceProxyUrl.indexOf( tagString ) == -1 ){
            sourceProxyUrl = sourceProxyUrl + "?tags=" + tagString;
        }
        //alert( "Process Records: " + sourceProxyUrl );
        var updatedSourceStore = new CQ.Ext.data.Store({
            url: sourceProxyUrl,
            reader: reader
        });

        updatedSourceStore.load();
        firstGrid.reconfigure( updatedSourceStore, columnModel );

        var tempHeadlineData = loadStore.getAt(0).get('headline');
        if( tempHeadlineData != undefined ){
            tempHeadlineData = "" + tempHeadlineData;
            var headlineData = new Array();
            headlineData = tempHeadlineData.split('_,');

            var tempPathData = loadStore.getAt(0).get('path');
            var tempDateData = loadStore.getAt(0).get('date');

            var secondGridData = [];

            for( var i = 0; i < headlineData.length; i++ ){
                var headline = headlineData[i].replace('_', '');
                secondGridData.push({headline: headline, path: tempPathData[i], date: tempDateData[i]});
            }

            //destination store
            var destinationStore = new CQ.Ext.data.Store({
                reader: reader
            });

            destinationStore.loadData( secondGridData );

            secondGrid.reconfigure( destinationStore, columnModel );
        }

    }
});
CQ.Ext.reg("gridtogrid", CQ.form.GridtoGrid);

2. Create a component in CQ and in the dialog add a widget node with the following properties.

Screen Shot 2013-10-04 at 12.18.59 PM

  • xtype : gridtogrid –> this matches to what you register the widget as in the javascript.
  • dataSrcUrl –> This is the URL which returns a JSON object that is consumed by the JSONReader in the widget JS
  • jsonReaderConfig –> Configuration for the JSON reader object
    • example: [{name: 'headline', mapping: 'headline'},{name:'date', mapping:'date'}]
  • columnModelConfig –> Configuration for the column model to be used in the left grid.
    • example: [{header: 'Headline', width: 500, sortable: false, dataIndex: 'headline'},{header: 'Date', width: 200, sortable: false, dataIndex: 'date', hidden: true}]
  • sortField –> The name of the field that the data should be sorted on
  • sortDirection –> ASC/DESC
  • name –> name of property on the content node to store the destination list under. It will stored as a String[].

Screen Shot 2013-10-04 at 12.28.30 PM

3. Add the component on your page and double click to open the dialog. You should now see a list on the left and can populate the right based on the capabilities described above (double clicks).

Screen Shot 2013-10-04 at 12.39.13 PM

 

 

4. An example servlet that returns a JSON object to be consumed by the widget above is below

final ValueMap attributes = resource.adaptTo(ValueMap.class);
try{
TidyJSONWriter writer = new TidyJSONWriter(out);
String headline = "Headline Not Set";
Calendar date = null;
String stringDate = null;
SimpleDateFormat sdf = new SimpleDateFormat( "yyyy-MM-dd" );
String path = null;

String tagsParameter = request.getParameter( "tags" );

if( tagsParameter != null ){
String[] tags = tagsParameter.split( "," );

NodeIterator articleList = Headlines.getArticleListByTags( currentNode.getSession(), tags, 25 );

if( articleList != null && articleList.getSize() > 0 ){
writer.array();
for( NodeIterator ni = articleList; articleList.hasNext(); ){
Node article = articleList.nextNode();

//Get Headline
try{
headline = article.getProperty( "headline" ).getValue().getString();
} catch (Exception e){
log.info( "headline doesn't exist, using static value instead" );
}

//Get Date
Node contentNode = currentNode.getSession().getNode( article.getPath().replace( "/contentpar/articleBody", "" ) );
try{
date = contentNode.getProperty( "cq:lastModified" ).getValue().getDate();
} catch (Exception e){
log.info( "cq:lastModified doesn't exist, using jcr:created instead" );
date = contentNode.getProperty( "jcr:created" ).getValue().getDate();
}

//Convert date to String
if( date != null ){
stringDate = sdf.format( date.getTime() );
} else {
log.info( "Date Object was null" );
}

//Get Path
path = article.getPath().replace("/jcr:content/contentpar/articleBody","");
writer.object();
writer.key("headline").value( headline );
writer.key("path").value( path );
writer.key("date").value(stringDate);
writer.endObject();
}
writer.endArray();
}

} else {
NodeIterator articleList = Headlines.getArticleListByModifiedDate( currentNode.getSession(), 25 );

if( articleList != null && articleList.getSize() > 0 ){
writer.array();
for( NodeIterator ni = articleList; articleList.hasNext(); ){
Node article = articleList.nextNode();
Node bodyNode = currentNode.getSession().getNode( article.getPath() + "/contentpar/articleBody" );

//Get Headline
try{
headline = bodyNode.getProperty("headline").getValue().getString();
} catch( Exception e ){
log.info( "headline doesn't exist, using static value instead" );
}

//Get lastModifiedDate
try{
date = article.getProperty( "cq:lastModified" ).getValue().getDate();
} catch (Exception e){
log.info( "cq:lastModified doesn't exist, using jcr:created instead" );
date = article.getProperty( "jcr:created" ).getValue().getDate();
}

if( date != null ){
stringDate = sdf.format( date.getTime() );
} else {
log.info( "Date Object was null" );
}

//Get Path
path = bodyNode.getPath().replace("/jcr:content/contentpar/articleBody","");

writer.object();
writer.key("headline").value( headline );
writer.key("path").value( path );
writer.key("date").value(stringDate);
writer.endObject();

}
writer.endArray();
}
}

} catch (Exception e){
log.info( e.getMessage() );
}

I can post a video of the widget in action if this blog post isn’t descriptive  enough. As always, please post comments/questions and I will try to get back to you as soon as possible.

12:46 PM Permalink
February 25, 2012

How to integrate CQ5 with Defensio Profanity detection

I have recently worked on a project where Defensio integration is needed to detect profanity in CQ5′s commenting system and discussion forums. Adobe CQ5 comes with OOTB integration with Automattic Kismet (Akismet) which is for Spam filtering but Akismet does not offer profanity filtering. For more information on Adobe CQ5′s integration with Akismet, please look at the following links:

Social Collaboration – Setting an Akismet Key

AkismetService API doc

Defensio is a profanity and spam filtering service on user generated content. It offers a fully RESTful API to automatically detect, reject, and mask profanity from user generated content on a website. To learn more about Defensio, please visit:

Defensio Web Service – Powered by WebSense

Defensio API Specification

 

Now let’s talk about the high level requirements of the integration I have recently worked on, before I go into the technical details.

Requirements:

  • Profanity detection should be enabled for any comments or discussion forum replies (user generated content).
  • If obscene words are found, they should be masked and the comment / forum reply should be marked as spam. The spam message should not be displayed unless approved by a soco (social collaboration) admin user.
  • No profanity messages should be displayed publicly. If it should be displayed (approved by admin), the masked version is used.
  • The Defensio Web Service API URL and also the API key should be configurable.

Technical Details:

  • A workflow process (com.day.cq.workflow.exec.WorkflowProcess) should be created for the profanity check so that it can be enabled in any CQ workflows.
  • To enable profanity detection for comments or discussion forum replies, the workflow process can be inserted into the OOTB “Comment Moderation” workflow.
  • In order to allow system administrators to configure the Defensio Web Service URL and the API key, an OSGi bundle can be created with service component runtime properties like the following:

 

OSGi Bundle implementation:

This blog entry is not to discuss the very detail of how to package and deploy an OSGi Bundle into Apache Felix. The code snippets I’m showing below utilized the Apache Maven software project management tool, and the codes are greatly shorted (life is easy with Java Annotations) using the maven-bundle-plugin and maven-scr-plugin. To learn more about these plugins, here are some links:

Apache Felix Bundle Plugin for Maven

Apache Felix Maven SCR Plugin

The OSGi bundle for the Defensio integration consists of three classes:

com.customername.cq.profanity.DefensioService
com.customername.cq.profanity.DefensioServiceImpl
com.customername.cq.profanity.DefensioProcess

DefensioService is the interface for the Defensio Profanity integration,
DefensioServiceImpl is an implementation of the integration, and
DefensioProcess is a CQ workflow process implementation (makes it available as a process step in a CQ workflow).

So here you go, please note the use of annotations, and also note that some of logs and minor implementation details are skipped (marked by ellipses in comments):

com.customername.cq.profanity.DefensioService:
package com.customername.cq.profanity;
 
public interface DefensioService {
 
    /**
     * Check to see if the Defensio API key has been verified
     *
     * @return <code>true</code> if the API key has been verified, <code>false</code> otherwise
     */	
	public boolean isVerifiedKey();
 
    /**
     * Verify Defensio API key
     *
     * @return <code>true</code> if the API key has been verified, <code>false</code> otherwise
     */	
	public boolean verifyAPIKey();
 
	@SuppressWarnings("unchecked")
	public String profanityCheck(String commentContent);
}
com.customername.cq.profanity.DefensioServiceImpl:
package com.customername.cq.profanity;
 
import java.io.IOException;
import java.util.Dictionary;
 
import org.apache.commons.httpclient.DefaultHttpMethodRetryHandler;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PostMethod;
import org.apache.commons.httpclient.params.HttpClientParams;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.sling.commons.json.JSONException;
import org.apache.sling.commons.json.JSONObject;
import org.osgi.framework.BundleContext;
import org.osgi.service.component.ComponentContext;
 
/**
 * Defensio Service implementation
 * <p></p>
 * <a href="http://http://www.defensio.com/api">Defensio API</a> documentation.
 *
 * @author Oliver Choy
 *
 * @scr.component label="Adobe CQ AntiProfanity"
 *                description="Defensio configuration"
 * @scr.property name="service.vendor" value="Customer Name"
 * @scr.property name="service.description" value="Defensio Profanity Check for blog comments"
 * @scr.service
 */
public class DefensioServiceImpl implements DefensioService {
 
    private Log logger = LogFactory.getLog(DefensioServiceImpl.class);
 
    // Constants
    private static final String USER_AGENT_HEADER = "User-Agent";
    private static final String USER_AGENT_VALUE = "Adobe CQ5";
    private static final String API_PARAMETER_COMMENT_CONTENT = "comment_content";
 
    private HttpClient httpClient;
    private String apiKey;
    private boolean verifiedKey = false;
    private String providerUrlFragment;
    
    /** @scr.property */
    public static final String PARAM_API_KEY = "defensio.service.api.key";
    /** @scr.property */
    public static final String PARAM_API_URL = "defensio.service.api.url";
 
    public DefensioServiceImpl() {
    }
 
    DefensioServiceImpl(BundleContext bundleContext, Dictionary<String, Object> configuration) {
        this.setup(bundleContext, configuration);
    }
 
    void setup(BundleContext bundeContext, Dictionary<String, Object> configuration) {
    	// get api key
        Object key = configuration.get(PARAM_API_KEY);
        if (key != null) {
            this.apiKey = key.toString();
        }
        // get api url
        Object apiUrl = configuration.get(PARAM_API_URL);
        if (apiUrl != null) {
            this.providerUrlFragment = apiUrl.toString();
        }
 
        // autoverify
        if(providerUrlFragment != null && apiKey != null) {
            if(providerUrlFragment.length() > 0 && apiKey.length() > 0) {
        	logger.info("Defensio API key verification result is " + verifyAPIKey());
            }
        }
    }
 
    void shutdown() {
    	this.apiKey = null;
    }
 
    @SuppressWarnings("unchecked")
    protected void activate(ComponentContext context) {
	httpClient = new HttpClient();
	HttpClientParams httpClientParams = new HttpClientParams();
	DefaultHttpMethodRetryHandler defaultHttpMethodRetryHandler = new DefaultHttpMethodRetryHandler(0, false);
	httpClientParams.setParameter(USER_AGENT_HEADER, USER_AGENT_VALUE);
	httpClientParams.setParameter(HttpClientParams.RETRY_HANDLER,
		defaultHttpMethodRetryHandler);
	httpClient.setParams(httpClientParams);
	setup(context.getBundleContext(), context.getProperties());
    }
 
    protected void deactivate(ComponentContext context) {
        this.shutdown();
    }
 
    public boolean verifyAPIKey() {
    	boolean callResult = false;
        String defensioUserURL = "http://" + providerUrlFragment + apiKey + ".json";
        GetMethod get = new GetMethod(defensioUserURL);
 
        try {
            httpClient.executeMethod(get);
            String result = get.getResponseBodyAsString();
            
            if (result != null && !result.equals("")) {
            	JSONObject respObj = new JSONObject(result);
            	JSONObject defensioResultObj = respObj.getJSONObject("defensio-result");
            	if (defensioResultObj != null) {
            	    String verifyStatus = defensioResultObj.getString("status");
                    if (verifyStatus.equals("success")) {
            		callResult = true;
            	    }
            	} 
            }
        } catch (IOException e) {
            logger.error("...");
        } catch (JSONException e) {
      	    logger.error("...");
	}
 
        verifiedKey = callResult;
        return callResult;
    }
 
    /**
     * Generic call to Defensio
     **/
    @SuppressWarnings("unchecked")
	private String defensioCall(String commentContent) {
    	
    	String filteredResult = "";
        String defensioProfanityURL = "http://" + providerUrlFragment + apiKey + "/profanity-filter.json";
 
        PostMethod post = new PostMethod(defensioProfanityURL);
        post.addRequestHeader("Content-Type", "application/x-www-form-urlencoded; charset=utf-8");
 
        if (commentContent != null) {
            post.addParameter(new NameValuePair(API_PARAMETER_COMMENT_CONTENT, commentContent));
        }
 
        try {
            httpClient.executeMethod(post);
            String result = post.getResponseBodyAsString();
 
            if (result != null && !result.equals("")) {
            	JSONObject respObj = new JSONObject(result);
            	JSONObject defensioResultObj = respObj.getJSONObject("defensio-result");
            	if (defensioResultObj != null) {
            	    String verifyStatus = defensioResultObj.getString("status");
            	    if (verifyStatus.equals("success")) {
            		JSONObject filteredObject = defensioResultObj.getJSONObject("filtered");
            		if (filteredObject != null) {
            		    filteredResult = filteredObject.getString(API_PARAMETER_COMMENT_CONTENT);
            		    logger.debug("Defensio filtered comment: " + filteredResult);
            		}
            	    }
            	} 
            }
        } catch (IOException e) {
            logger.error("...");
        } catch (JSONException e) {
       	    logger.error("...");
	}
        return filteredResult;
    }
 
    @SuppressWarnings("unchecked")
	public String profanityCheck(String commentContent) {
        return defensioCall(commentContent);
    }
    
    public boolean isVerifiedKey() {
        return verifiedKey;
    }
}
com.customername.cq.profanity.DefensioProcess:
package com.customername.cq.profanity;
 
import javax.jcr.Node;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
 
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.jcr.resource.JcrResourceResolverFactory;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
 
import com.day.cq.workflow.WorkflowException;
import com.day.cq.workflow.WorkflowSession;
import com.day.cq.workflow.exec.WorkItem;
import com.day.cq.workflow.exec.WorkflowData;
import com.day.cq.workflow.exec.WorkflowProcess;
import com.day.cq.workflow.metadata.MetaDataMap;
 
/**
 * This <code>JavaProcess</code> checks a comment or forum reply for profanity
 * 
 * @scr.component metatype="false"
 * @scr.service interface="com.day.cq.workflow.exec.WorkflowProcess"
 * @scr.property name="process.label" value="Defensio Profanity Check"
 */
public class DefensioProcess implements WorkflowProcess {
 
    private static final String COMMENT_RESOURCE_TYPE = "collab/commons/components/comments/comment";
    private static final String FORUM_POST_RESOURCE_TYPE = "collab/forum/components/post";
    private static final String FORUM_TOPIC_RESOURCE_TYPE = "collab/forum/components/topic"
    private static final String SLING_RESOURCE_TYPE = "sling:resourceType";
    private static final String TYPE_JCR_PATH = "JCR_PATH";
    private final Logger logger = LoggerFactory.getLogger(this.getClass().getName());
 
    /**
     * @scr.reference policy="static"
     */
    private DefensioService defensioService;
    /**
     * @scr.reference policy="static"
     */
    private JcrResourceResolverFactory jcrResourceResolverFactory;
 
    protected void activate(final ComponentContext context) {
        // check if the key is valid
        logger.info("Verifying key for Defensio check");
        boolean isValidKey = defensioService.isVerifiedKey();
        if (isValidKey) {
            logger.info("...");
        } else {
            logger.warn("...");
        }
    }
 
    /**
     * @see WorkflowProcess#execute(WorkItem, WorkflowSession, MetaDataMap)
     */
    public void execute(WorkItem workItem, WorkflowSession workflowSession, MetaDataMap args) throws WorkflowException {
        final Session session = workflowSession.getSession();
        final WorkflowData data = workItem.getWorkflowData();
        String path = null;
        String type = data.getPayloadType();
        try {
            if (type.equals(TYPE_JCR_PATH) && data.getPayload() != null) {
                String payloadData = (String) data.getPayload();
                if (session.itemExists(payloadData)) {
                    path = payloadData;
                }
            }
 
            if (path != null) {
                final Node userGeneratedNode = (Node) session.getItem(path);
 
                if (userGeneratedNode.hasProperty(SLING_RESOURCE_TYPE) &&
                        (userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(COMMENT_RESOURCE_TYPE) ||
                        	userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(FORUM_POST_RESOURCE_TYPE) || 
                                userGeneratedNode.getProperty(SLING_RESOURCE_TYPE).getString().equals(FORUM_TOPIC_RESOURCE_TYPE))
                ) {
 
                    logger.debug("Profanity check for comment node at " + userGeneratedNode.getPath());
                    String ipAddress = null;
                    if (userGeneratedNode.hasProperty("ip")) {
                        ipAddress = userGeneratedNode.getProperty("ip").getString();
                    } else {
                        setIsSpam(userGeneratedNode, false, session);
                    }
                    // if the ip is localhost we set isspam to false as well
                    if (ipAddress.equalsIgnoreCase("127.0.0.1") || ipAddress.equalsIgnoreCase("localhost")
                            || ipAddress.equalsIgnoreCase("0:0:0:0:0:0:0:1")) {
                        setIsSpam(userGeneratedNode, false, session);
                    }
 
                    // UserAgent spam check ...
                    // referrer spam check ...
                    // Author spam check ...
                    // author email spam check ...
                    // author URL spam check ...
                    
                    String commentText = null;
                    if (userGeneratedNode.hasProperty("jcr:description")) {
                        commentText = userGeneratedNode.getProperty("jcr:description").getString();
                    }
 
                    String filteredText = defensioService.profanityCheck(commentText);
                    if (!filteredText.equals(commentText)) {
                    	setIsSpam(userGeneratedNode, true, session);
                    	setFilteredText(userGeneratedNode, filteredText, session);
                    }
                    logger.info("Filtered result " + filteredText + " for comment: " + workItem.toString());
 
                } else {
                    logger.warn("Cannot check for profanity because item is not a comment/topic/post. Workitem: "
                            + workItem.toString());
                }
            } else {
                logger.warn("Cannot check for spam because path is null for this workitem: " + workItem.toString());
            }
        } catch (RepositoryException e) {
            throw new WorkflowException(e);
        }
    }
 
    private void setFilteredText(Node commentNode, String value, Session session) throws WorkflowException {
        try {
            commentNode.setProperty("jcr:description", value);
            session.save();
        } catch (Exception e) {
            throw new WorkflowException(e);
        }
    }
    
    private void setIsSpam(Node commentNode, boolean value, Session session) throws WorkflowException {
        try {
            commentNode.setProperty("isSpam", value);
            session.save();
        } catch (Exception e) {
            throw new WorkflowException(e);
        }
    }
}

Workflow Configuration:

Once the OSGi bundle is implemented and installed, a Process Step can be added to the CQ comment moderation workflow to trigger the profanity check.

Note that the Process Step, once dragged into the workflow, will need to be configured to use the OSGi bundle we have installed. In the example above, the Process Step is named as “Check Profanity” and the process chosen is “Defensio Profanity Check”, like the following:

 

Profanity Detection Result:

Once all of the above are implemented, installed, and configured, you can test the comment entry and discussion forum reply in CQ. It should yield something similar to the following:

The above image depicts two commenting scenarios (in CQ author environment): The first entry without profanity was successfully inserted into the system; the second comment was masked due to discovery of profanity, and was marked as spam waiting for approval/denial of a social collaboration administrator.

1:20 PM Permalink
December 1, 2011

Custom Component: FAQ (Accordion) Paragraph System

In this blog post I would like to talk about a more complicated version of the FAQ Component, which was previously blogged here.

The Simple FAQ Component is fairly limited when it comes to adding new features into the FAQ Answers (richtext xtype). Customers might sometimes want to be able to add non-textual components into the answers fields. For example, they might want to:

  • Add an image to an answer.
  • Embed a video in an answer.
  • Add a table, list of children pages, or even a site map.
To support any of the above, the Simple FAQ Component would require major modification which would impact existing content nodes (A major No No!). Now, let’s combine all of the requirements from the Simple FAQ Component with the features listed above, and consider the following single use case:
  • User can drag and drop multiple components from the sidekick into a region on a page, and the components (movable) inside the region would be rendered inside a jQuery UI Accordion.
This use case sounds very much like the CQ5 out-of-the-box Paragraph System, where components can be drag-and-drop’ed and be moved around. The missing piece here is the presentation of the components in an expandable/collapsible grouping system.
Applying to the FAQ concept, let me introduce an Accordion Paragraph System. Take a look at the demo below and we’ll dive into the overview.

Demo:

Overview of implementation:

The FAQ Paragraph System actually consists of multiple components. The following steps are grouped by individual components, and for best practice these components are placed inside the same folder (faqparsys). Please take a look at the diagram below for the overall structure of the parsys:

clientlibs [cq:ClientLibraryFolder]:
  • containing javascript to trigger the jQuery UI Accordion.
faqparsys [cq:Component]:
  • Much like the OOTB parsys component. The only difference is the rendering of children components in Accordion order.
  • faqparsys would loop through all children components. If component is of type faquestion [cq:Component], then it would render the faquestion as an Accordion header. Otherwise components (before the next faquestion component) are grouped as Accordion content.
  • At the end of the faqparsys render, the faqparsysend component is included to signify the end of the paragraph system (for better user experience).
  • The faqparsys is set to refresh the page on the following listeners so the jQuery UI Accordion would re-trigger and display correctly:
    • afterdelete
    • afteredit
    • afterinsert
faquestion [cq:Component]:
  • Simple extension of the OOTB Title Component, so that the type “faquestion” can be identified by the faqparsys.
faqparsysend [cq:Component]:
  • This component renders the editbar layout. It is used to signify the end of the paragraph system.

 

Sample of the finished component:

Enjoy! Again, part of the source-code can be made available upon request. Please email me directly at ochoy@adobe.

–EDIT 2011/12/08– Due to the number of requests I’m getting regarding the source code, please kindly understand that there will be a slight delay in my responses.

 

5:03 PM Permalink

Custom Component: FAQ component (Simple)

Basic Requirements:

  • Authors can drag and drop the FAQ Component from the CQ5 sidekick to a paragraph system.
  • In the dialog of the FAQ Component, authors can type in multiple question-and-answer pairs.
  • The FAQ Component uses jQuery UI to render the questions and answers inside an Accordion. Clicking on the questions would expand the answers.

Prerequisites:

  • Multi Composite Fields widget should be registered.
  • jQuery UI Library added as a clientlibs in the system.

Demo:


Simple FAQ Component Demo

High Level Overview on creating this custom component:

  1. In CRXDE, create a new component (cq:Component) of FAQ. Set the following properties:
    • allowedParents - */parsys
    • sling:resourceSuperType - foundation/components/parsys
  2. Create a cq:Dialog with the following structure:
    • dialog [cq:Dialog] > items [cq:TabPanel] > items [cq:WidgetCollection] >
      faqPanel [cq:Panel] > items [cq:WidgetCollection] > faqEntries[cq:Widget] >
      fieldConfigs [cq:WidgetCollection]
  3. The faqEntries [cq:Widget] node would be using xtype of the Multi Composite Fields.
  4. Inside the fieldConfigs [cq:WidgetCollection] node, create the necessary fields to capture the question and answer pair. For example:
    • answer [cq:Widget] with xtype = richtext
    • question [cq:Widget] with xtype = textfield
  5. Specify the appropriate listeners to refresh the page so that modification to the FAQ component would trigger (or re-trigger) the jQueryUI Accordion.
  6. Modify the component JSP file so that the generated markup of question-answer pairs would fulfill jQueryUI’s markup requirement.
  7. Finally, create a cq:ClientLibraryFolder for the FAQ Component. It should contain a javascript file to render the Accordion.

Sample of the component:

1:24 AM Permalink