Alfresco ScriptNodes and jsonUtils

I’m using Alfresco Enterprise v4.1.1 for a project and I found a bit of a hole in the jsonUtils object.

Here’s what I tried to do in a webscript:

var users = people.getMembers(people.getGroup("GROUP_MyGroup"));
var json = jsonUtils.toJSONString(users);

 

The first line returns a javascript array of org.alfresco.repo.jscript.ScriptNodes (3 persons). These are the node objects you use in your webscripts.
The second line is supposed to provide a valid JSON string representing the user array.
The “jsonUtils” object available in webscripts uses org.springframework.extensions.webscripts.json.JSONUtils.

What I get for my array of 3 users looks something like this:

"Node Type: {http://www.alfresco.org/model/content/1.0}person, Node Aspects: [...]",
"Node Type: {http://www.alfresco.org/model/content/1.0}person, Node Aspects: [...]",
"Node Type: {http://www.alfresco.org/model/content/1.0}person, Node Aspects: [...]"

 

I’ve done some slight editing to save space here but it’s not valid JSON and not very useful at all. It turns out this is the output from ScriptNode.toString() for each user.

However ScriptNode also provides a handy toJSON() method.
Here’s some Java I wrote to add support for both a single ScriptNode and an array of ScriptNodes:

public String toJSONString(Object object) throws IOException {

  JSONUtils jsonUtils = new JSONUtils();

  if (object instanceof ScriptNode) {
    // the true tells it to use short QNames
    // eg. cm:name instead of {http://www.alfresco.org/model/content/1.0}name
    return ((ScriptNode) object).toJSON(true);

  } else {
    // is it an array of ScriptNodes?
    if (object instanceof NativeArray) {

      NativeArray array = (NativeArray) object;

      // get first item and see if it's a ScriptNode
      Object firstItem = array.get(0, array);

      if (firstItem instanceof ScriptNode) {
        StringBuffer json = new StringBuffer("[");
        ScriptNode node = (ScriptNode) firstItem;
        json.append(node.toJSON(true));

        for (int i = 1; i < array.getLength(); i++) {

          json.append(",");
          Object value = array.get(i, array);
          node = (ScriptNode) value;
          json.append(node.toJSON(true));
        }
        json.append("]");
        return json.toString();
      }
    }

    // if it's not a ScriptNode or array of ScriptNodes let jsonUtils handle it
    return this.jsonUtils.toJSONString(object);
  }
}

 

I expose this code to javascript with an extension name of ‘jsonUtilsTim’ and now the webscript looks like this:

var users = people.getMembers(people.getGroup("GROUP_MyGroup"));
var json = jsonUtilsTim.toJSONString(users);

 

Now I get the following which is valid JSON and actually useful:

[
 {
 "mimetype": "application/octet-stream",
 "aspects": [
 "app:configurable",
 "cm:ownable",
 "sys:referenceable",
 "sys:localized"
 ],
 "nodeRef": "workspace://SpacesStore/770df4a0-78ff-4c46-aca9-0de5ff12345",
 "properties": {
 "cm:name": "770df4a0-78ff-4c46-aca9-0de5ff512345",
 "sys:node-dbid": 27,
 "cm:email": "admin@alfresco.com",
 "cm:organizationId": "",
 "sys:store-identifier": "SpacesStore",
 "sys:locale": "en_US",
 "cm:homeFolderProvider": "bootstrapHomeFolderProvider",
 "cm:userName": "admin",
 "cm:sizeCurrent": null,
 "cm:owner": "admin",
 "sys:node-uuid": "770df4a0-78ff-4c46-aca9-0de5ff512345",
 "cm:lastName": "",
 "sys:store-protocol": "workspace",
 "cm:homeFolder": "workspace://SpacesStore/4f4ff00d-c94f-4a99-a304-cbe2e2912345",
 "cm:firstName": "Administrator"
 },
 "type": "cm:person"
 },
 {
 "mimetype": "application/octet-stream",
 "aspects": [
 "cm:ownable",
 "sys:referenceable",
 "sys:localized",
 "cm:personDisabled",
 "cm:preferences"
 ],
 "nodeRef": "workspace://SpacesStore/b6d80d49-21cc-4f04-9c92-e70630312345",
 "properties": {
 "sys:locale": "en_US",
 "cm:companyaddress3": "UK",
 "cm:homeFolderProvider": "userHomesHomeFolderProvider",
 "cm:companytelephone": "",
 "cm:owner": "admin",
 "cm:jobtitle": "Web Site Manager",
 "cm:preferenceValues": "contentUrl=store://2012/10/26/15/16/146c1e27-3980-4c21-b66c-397d97512345.bin|mimetype=text/plain|size=817|encoding=UTF-8|locale=en_US_|id=127",
 "cm:homeFolder": "workspace://SpacesStore/5da679f6-e7f3-4f8d-9506-554ed7312345",
 "cm:instantmsg": "",
 "cm:sizeQuota": -1,
 "cm:googleusername": "",
 "cm:firstName": "Jane",
 "cm:emailFeedId": 442,
 "cm:name": "b6d80d49-21cc-4f04-9c92-e70630312345",
 "cm:userStatusTime": "Tue Feb 15 13:13:09 MST 2011",
 "sys:node-dbid": 562,
 "cm:email": "jane.doe@example.com",
 "sys:store-identifier": "SpacesStore",
 "cm:companyfax": "",
 "sys:node-uuid": "b6d80d49-21cc-4f04-9c92-e70630312345",
 "cm:lastName": "Doe",
 "cm:persondescription": "contentUrl=store://2012/10/26/15/16/8f13b84b-f797-4176-bcce-cdff90912345.bin|mimetype=application/octet-stream|size=54|encoding=UTF-8|locale=en_US_|id=128",
 "cm:companyemail": "",
 "cm:sizeCurrent": null,
 "cm:userName": "jane.doe",
 "sys:store-protocol": "workspace"
 },
 "type": "cm:person"
 },
 {
 "mimetype": "application/octet-stream",
 "aspects": [
 "app:configurable",
 "cm:ownable",
 "sys:referenceable",
 "sys:localized"
 ],
 "nodeRef": "workspace://SpacesStore/c530d479-9a78-4c14-94c7-933b46412345",
 "properties": {
 "cm:name": "c530d479-9a78-4c14-94c7-933b46412345",
 "sys:node-dbid": 6939,
 "cm:organizationId": "",
 "cm:email": "john.doe@example.com",
 "sys:store-identifier": "SpacesStore",
 "sys:locale": "en_US",
 "cm:presenceUsername": "",
 "cm:homeFolderProvider": "userHomesHomeFolderProvider",
 "cm:owner": "john.doe",
 "cm:organization": "",
 "cm:jobtitle": "",
 "sys:node-uuid": "c530d479-9a78-4c14-94c7-933b46412345",
 "cm:lastName": "Doe",
 "cm:homeFolder": "workspace://SpacesStore/15a8f7fb-87ee-4a7e-9b4d-c2f3a8f12345",
 "cm:presenceProvider": "",
 "cm:location": "",
 "cm:sizeQuota": -1,
 "cm:sizeCurrent": null,
 "cm:userName": "john.doe",
 "sys:store-protocol": "workspace",
 "cm:firstName": "John"
 },
 "type": "cm:person"
 }
 ]

Scheduling a “nag” email using Activiti Workflow and Alfresco

Sometimes users need to be reminded of outstanding tasks that have been neglected for a specified period of time. A helpful way to do this is to send them a nice email. For a recent project, I cobbled together an Activiti workflow that had various such “nag” steps that would fire off an email if a task became stagnant for too long, and I thought I’d share some code snippets on how I did it.

Workflow

I’ve simplified the workflow to a single step, plus the “nag” task for brevity:

You could easily imagine this as a part of a much more complex workflow, perhaps with multiple steps requiring timed email notifications.

Classify Task

If the Classify task is idle for more than 14 days, we kick off a friendly reminder email. Here’s the Classify Article workflow step.

<userTask id=”classifyArticle” name=”Classify Article” activiti:candidateGroups= “${ATCWF_classifierGroup}” activiti:formKey=”ATCWF:classifyArticle”>
    <extensionElements>
      <activiti:taskListener event=”complete”>
        <activiti:field name=”script”>
          <activiti:string>classification = task.getVariable(‘ATCWF_classification’);
              execution.setVariable(‘ATCWF_classification’,classification);
          </activiti:string>
        </activiti:field>
      </activiti:taskListener>
    </extensionElements>
 </userTask>
 <boundaryEvent id=”classifyTimer” name=”Classify Nag Timer” cancelActivity=”false” attachedToRef=”classifyArticle”>
    <timerEventDefinition>
      <timeCycle>${ATCWF_nagTimerDuration}</timeCycle>
    </timerEventDefinition>
</boundaryEvent>

So when this task is completed, it grabs the ATCWF_classification variable chosen by the user on the workflow’s form, and stores it in an Activiti workflow variable for later use. Not overly useful, but good enough for demonstration.

I’ve attached a boundary event, “classifyTimer”, to this task which will fire after a timeCycle determined by the ATCWF_nagTimerDuration Activiti workflow variable.

For the required 14 days, the value will be R/P14D (“repeat every period of 14 days”, according to ISO 8601 format.) Make sure cancelActivity is set to false so that the workflow won’t be suspended while we’re waiting.

A Brief Aside

I’d like to make a brief aside to comment on a useful “trick” I’ve used in my code snippets that might be useful to others, although it is not necessary for our task of creating a “nag” email.

Both ${ATCWF_nagTimerDuration} and ${ATCWF_classifierGroup} are Activiti workflow variables that I declared and set in a step prior to this Classify Article task.

This let me store those values in a configuration file in the underlying Alfresco repository, allowing them to be changed without having to redeploy the workflow. Which is nice, since deploying a new workflow doesn’t impact in-flight workflows, but it is possible by altering the value of some workflow variables.

This “trick” could be applied to change what group a later workflow step is assigned to, depending on choices made by a user in an earlier step, cutting down on extraneous exclusive gateways and tasks needed to account for all possible choices.


Email Task

Now let’s look at the step that’ll send a reminder email every 14 days.

<serviceTask id=”classifyNagEmail” name=”Nag Email” activiti:class=”org.alfresco.repo.workflow.activiti.script.AlfrescoScriptDelegate” activiti:async=”true” activiti:exclusive=”false”>
    <extensionElements>
      <activiti:field name=”script”>
        <activiti:string>
            var article = bpm_package.children[0];

                   // construct notification email
                  var subject = “An article needs to be classified”;
                  var emailTemplatePath = “”Data Dictionary/Email Templates/Notify Email Templates/ATC”;
                  var emailTemplate = companyhome.childByNamePath(emailTemplatePath+”/notify_reminder.ftl”);
                  var emailFrom = “reminders@abstractive.ca”;

                  var recipient = ATCWF_classifierGroup;
     
                  var mail = actions.create(“mail”);
                  mail.parameters.to_many  = recipient;
                  mail.parameters.subject   = subject;
                  mail.parameters.from = emailFrom;
                  mail.parameters.template = emailTemplate;
                  // placeholder body text; the template will have the message
                  mail.parameters.text = “Task requires action”;

           // pass article properties to the template layer for easy email substitution
           var templateArgs = new Array();
           var articleId = article.properties['sys:node-dbid'];
           templateArgs['articleId'] = articleId + “”;
           templateArgs['articleTitle']    = article.properties['cm:title'];
           templateArgs['reviewPeriod']    = ATCWF_nagTimerValue;

                  var templateModel = new Array();
                  templateModel['args'] = templateArgs;
                  mail.parameters.template_model = templateModel;

           mail.execute(article);
       </activiti:string>
    </activiti:field>
  </extensionElements>
</serviceTask>

Here we see the construction of an Alfresco mail action, the retrieval of a FreeMarker template from the repository for the message body, and passing the Activiti workflow variables and package properties to the template so the email can give more specific information about the article needing attention.

I simplified things a bit in the above snippet, as I normally put static variables such as the email subject, email template path etc. as properties in a configuration file for the same reasons I mentioned earlier.

Hopefully it’s not too difficult to see how you could extend the concepts and “tricks” I’ve shown in these two steps to make an intelligent workflow capable of making decisions and notifying pesky users to take care of neglected tasks.

Alfresco Process Template on Classpath

Processing a template that is on the classpath instead of in the repository:

I’m deploying an Alfresco module (AMP) with a scheduled script that processes a FreeMarker template. The script resides on the classpath instead of in the repository and I thought it would be cleaner to deploy if the template was also on the classpath. I was sure that processing a FreeMarker template from the classpath must be functionality available in Alfresco out-of-the-box. However, after searching tirelessly for as long as my attention span would allow (admittedly not long) I couldn’t find such a call. So here is what I did…

All work was done on Alfresco 3.2r Enterprise.

The standard JavaScript API allows you to call processTemplate(template, args) on a node. “template” is either a string containing the FreeMarker or a node whose contents contains the FreeMarker. So I looked at org.alfresco.repo.jscript.ScriptNode and in particular at:

public String processTemplate(String template, Object args)

Then I looked at org.alfresco.repo.jscript.ClasspathScriptLocation since I knew the scheduled script bean uses it for the script which is on the classpath.

As described in the Alfresco Wiki (http://wiki.alfresco.com/wiki/3.2_JavaScript_API#Adding_Custom_Script_APIs) I extended the JavaScript API. I’ve already got several handy extensions in a NodeUtilScript Java class with an extension name of “nodeUtil” so I just added to it as follows:

/**
 * Process a FreeMarker Template against the given node.
 *
 * @param templateLocation the classpath of the template to execute
 * @param args Scriptable object (generally an associative array) containing the name/value pairs 
 *			   of arguments to be passed to the template
 * @param node the node to process the template against
 * @return output of the template execution
 * @throws AlfrescoRuntimeException
 */
public String processClasspathTemplate(String templateLocation, Object args, ScriptNode node) {

   ClasspathScriptLocation location = new ClasspathScriptLocation(templateLocation);

   try {
      // retrieve template content
      String template = IOUtils.toString(location.getInputStream());

      // process template
      return node.processTemplate(template, args);

   } catch (IOException ioe) {
      throw new AlfrescoRuntimeException("Error retrieving template", ioe);
   }
}

There’s not much to it:

  • create a ClasspathScriptLocation with a path to the template
  • get the template contents as an InputStream
  • use IOUtils.toString(InputStream) from Apache Commons IO to pull the template into a string
  • call processTemplate() on the given node using the retrieved template string, returning the result

It can then be called from my scheduled script (or from a web script).

Calling Script:

// get a node

var myNode = search.findNode("workspace://SpacesStore/c7e27390-12f0-44dd-b89b-ef63a8320d6b");

// prepare some data to pass to the template
var args = new Array();
args["arg1"] = "Hello";
args["arg2"] = "The World";

// process the template agains the node
var result = nodeUtil.processClasspathTemplate("alfresco/templates/email/MyTemplate.ftl", args, myNode);

MyTemplate.ftl:

I say, ${args["arg1"]!} to ${args["arg2"]!}.

Node-uuid is ${document.properties["sys:node-uuid"]}

So after executing the above script “result” contains “I say, Hello to The World. Node-uuid is c7e27390-12f0-44dd-b89b-ef63a8320d6b”.

Having my template deployable within my AMP really simplifies moving from development to UAT to production repositories. I don’t have to remember to actually upload it to the repository separately or to export it as an ACP and bootstrap it.

Of course, in many cases the template is in the repository for a very good reason – so that changes can be made to it by less technical users and those changes can be seen without restarting Alfresco. But it’s still nice to have the choice.

References:

Alfresco Wiki, 3.2 JavaScript API, Adding Custom Script APIs

http://wiki.alfresco.com/wiki/3.2_JavaScript_API#Adding_Custom_Script_APIs

Apache Commons IO (http://commons.apache.org/io/)

IOUtils (http://commons.apache.org/io/api-release/org/apache/commons/io/IOUtils.html)