Monday, June 1, 2020

Unlock a node in Alfresco which could not be unlocked


I had an issue recently when i was trying to edit a word document. I deleted the working copy node somehow. I have no clues how it happend. But this word document node was locked foreever. Cancel checkout action was always failing. I was not able to cancel checkout and unlock the node.

While i was investigating the issue, i looked for the nodeRef of the word document via node browser. I found that it had cm:checkedOut aspect (because i checkedOut for edit oboviously).

I thought removing the aspect and unlocking will solve my problem. I tried to remove cm:checkedOut aspect from the node via repository js webscript but it failed. 

In my further investigation i reviewed, org.alfresco.service.cmr.coci.CheckOutCheckInService.cancelCheckout(NodeRef) method defintion in CheckOutCheckInService class:

public NodeRef cancelCheckout(NodeRef workingCopyNodeRef);

It indicated that, it expects workingCopyNode in order to cancel the checkout.

To unlock the node i have to somehow remove the cm:checkout aspect and then unlock the node. 

When i investigated more in logs (enabled the debug log on org.alfresco.repo.coci package), i saw that on click of Cancel Checkout, a backend behavior (org.alfresco.repo.coci.CheckOutCheckInServicePolicies.BeforeCancelCheckOut) was stopping the process. The below given method seemd to be validating working copy node by firing org.alfresco.repo.coci.CheckOutCheckInServicePolicies.BeforeCancelCheckOut.beforeCancelCheckOut(NodeRef) behavior.

org.alfresco.repo.coci.CheckOutCheckInServiceImpl.invokeBeforeCancelCheckOut(NodeRef)

The above validation was happening before the unlock (org.alfresco.service.cmr.lock.LockService.unlock(NodeRef, boolean, boolean)) method call. 

So i decided to disable the behaviors and then unlock the node followed by aspect removal. Here is the fix:

function main () { 
//using the string nodeRef of the word document which was locked. var nodeRef = search.findNode("workspace://SpacesStore/7c093a3d-c16e-416d-8087-9ccd93aa2a06"); var webContext = Packages.org.springframework.web.context.ContextLoader.getCurrentWebApplicationContext(); var behaviourFilter = webContext.getBean("policyBehaviourFilter", org.alfresco.repo.policy.BehaviourFilter);  
// Disable all behaviors, so we can remove the aspect without any validations behaviourFilter.disableAllBehaviours(); // Remove cm:checkedOut aspect and try to unlock the node which was not getting unlocked due to behavior validations nodeRef.removeAspect("cm:checkedOut"); nodeRef.unlock(); // Enable all the behaviors behaviourFilter.enableAllBehaviours(); 
} main();

Change Type of custom content types


If you have to change the content type (basically cm:content to a custom content type),  then there is an OOTB action provided in Share document library. 

This is very simple use case. But there could be a situation where you have multiple custom content types or folder types such as:

<types>	
	<type name="demo:whitePaper">
		<title>WhitePaper document</title>
		<description>A WhitePaper document</description>
		<parent>cm:content</parent>
	</type>
	
	<type name="demo:supportingDoc">
		<title>Supporting document</title>
		<description>A supporting document</description>
		<parent>cm:content</parent>
	</type>
</types>
Here, both demo:whitePaper and demo:supportingDoc custom content types are defined by extending "cm:content" type.
-----------------------------------------------------------------

With OOTB box change type action, if you have created a content/file which is of type "cm:content" then 
you can change the type from cm:content to demo:whitePaper or demo:supportingDoc based on the configuration made
in share config (share-config-custom.xml).

Example of share-config-custom.xml config:
<config evaluator="string-compare" condition="DocumentLibrary">
	<aspects>
		<visible/>
		<addable/>
		<removable/>
	</aspects>
	<types>
		<type name="cm:content">
			<subtype name="demo:whitePaper"/>
			<subtype name="demo:supportingDoc"/>
		</type>
	</types>
</config>
Change Type action uses a repository webscript, it calls: 
Document List Component - type submit at "/slingshot/doclib/type/node/{store_type}/{store_id}/{id}" url.

But if you have a requirement to allow changing from demo:whitePaper to demo:supportingDoc or vice-versa,
then OOTB box action will not work. However, you can create a custom webscript/action to achieve this use case
or extend the OOTB "Document List Component - type submit" webscript to allow handling custom content types.

Here, we will try to extend the "Document List Component - type submit" webscript to achieve our goal.

Follow the below given steps to extend the Change Type webscript as mentioned above:

  • Create "type.post.desc.xml" webscript definition file under extension/templates directory in following directory structure:
extension/templates/webscripts/org/alfresco/slingshot/documentlibrary/type.post.desc.xml

  • Add following content in the "type.post.desc.xml" file:
<!-- Extended the webScript for custom handling of change-type action -->
<webscript>
    <shortname>type</shortname>
    <description>Document List Component - type submit</description>
    <url>/slingshot/doclib/type/node/{store_type}/{store_id}/{id}</url>
    <format default="json">argument</format>
    <authentication>user</authentication>
    <transaction>required</transaction>
    <lifecycle>internal</lifecycle>
</webscript>

  • Create a java class, name it e.g.: "ChangeTypeWebscript",
This class extends "org.springframework.extensions.webscripts.AbstractWebScript"
  • Add following code in this class:
/*
 * Created By: Abhinav Kumar Mishra
 * Copyright &copy; 2020. Abhinav Kumar Mishra. 
 * All rights reserved.
 * 
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.github.abhinavmishra14.webscript;

import java.io.IOException;

import org.alfresco.repo.content.MimetypeMap;
import org.alfresco.service.cmr.repository.NodeRef;
import org.alfresco.service.cmr.repository.NodeService;
import org.alfresco.service.namespace.NamespaceService;
import org.alfresco.service.namespace.QName;
import org.apache.commons.lang3.StringUtils;
import org.apache.http.HttpStatus;
import org.json.JSONException;
import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.extensions.webscripts.AbstractWebScript;
import org.springframework.extensions.webscripts.Status;
import org.springframework.extensions.webscripts.WebScriptException;
import org.springframework.extensions.webscripts.WebScriptRequest;
import org.springframework.extensions.webscripts.WebScriptResponse;

/**
 * The Class ChangeTypeWebscript.<br>
 * Extension of OOTB change type WebScript.
 */
public class ChangeTypeWebscript extends AbstractWebScript {

	/** The Constant LOGGER. */
	private static final Logger LOGGER = LoggerFactory.getLogger(ChangeTypeWebscript.class);

	/** The Constant TYPE_PARAM_KEY. */
	private static final String TYPE_PARAM_KEY = "type";

	/** The Constant STORE_TYPE. */
	private static final String STORE_TYPE = "store_type";

	/** The Constant STORE_ID. */
	private static final String STORE_ID = "store_id";

	/** The Constant PARAM_ID. */
	private static final String PARAM_ID = "id";

	/** The Constant CONTENT_LENGTH. */
	private static final String CONTENT_LENGTH = "Content-Length";

	/** The Constant ENCODING_UTF_8. */
	private static final String ENCODING_UTF_8 = "UTF-8";

	/** The Constant CURRENT_KEY. */
	private static final String CURRENT_KEY = "current";

	/** The Constant CACHE_CONTROL. */
	private static final String CACHE_CONTROL = "cache-Control";
             /** The Constant NO_CACHE. */
             private static final String NO_CACHE = "no-cache"; 
	/** The Constant EXPIRES. */
	private static final String EXPIRES = "Expires";

	/** The Constant PRAGMA. */
	private static final String PRAGMA = "Pragma";

	/** The namespace service. */
	private final transient NamespaceService namespaceService;

	/** The node service. */
	private final transient NodeService nodeService;

	/**
	 * The Constructor.
	 *
	 * @param namespaceService the namespace service
	 * @param nodeService      the node service
	 */
	public ChangeTypeWebscript(final NamespaceService namespaceService, final NodeService nodeService) {
		super();
		this.namespaceService = namespaceService;
		this.nodeService = nodeService;
	}

	/*
	 * (non-Javadoc)
	 * 
	 * @see org.springframework.extensions.webscripts.WebScript#execute(org.
	 * springframework.extensions.webscripts.WebScriptRequest,
	 * org.springframework.extensions.webscripts.WebScriptResponse)
	 */
	@Override
	public void execute(final WebScriptRequest request, final WebScriptResponse response) throws IOException {
		QName targetType = null;
		try {
		   final JSONObject requestPayload = new JSONObject(request.getContent().getContent());
		   targetType = getTargetType(requestPayload);
	           LOGGER.info("Changing type, the targetType is: {}", targetType);
		   final NodeRef nodeRef = getNodeRef(request);
		   nodeService.setType(nodeRef, targetType);
		   final QName changedType = nodeService.getType(nodeRef);
		   writeResponse(response, changedType.getPrefixedQName(namespaceService));
		} catch (JSONException excp) {
		   LOGGER.error("Error occurred while changing the type {}", targetType, excp);
		   throw new WebScriptException("Change type failed!, targetType was: " + targetType, excp);
		}
	}

	/**
	 * Gets the target type.
	 *
	 * @param payload the payload
	 * @return the target type
	 */
	private QName getTargetType(final JSONObject payload) {
		try {
		  final String type = payload.getString(TYPE_PARAM_KEY);
		  QName qname;
		  if (type.indexOf(String.valueOf(QName.NAMESPACE_BEGIN)) != -1) {
		    qname = QName.createQName(type);
		  } else {
		    qname = QName.createQName(type, namespaceService);
		  }
		  return qname;
		} catch (JSONException jsonex) {
		   throw new WebScriptException(Status.STATUS_INTERNAL_SERVER_ERROR,
			 "Error occurred while extracting the target type from input json payload", jsonex);
		}
	}

	/**
	 * Write response.
	 *
	 * @param response    the response
	 * @param changedType the changed type
	 * @throws IOException the IO exception
	 */
	private void writeResponse(final WebScriptResponse response, final QName changedType) throws IOException {
		try {
		  final JSONObject responsePayload = new JSONObject();
		  responsePayload.put(CURRENT_KEY, changedType.getPrefixString());
		  writeResponse(response, responsePayload, false, HttpStatus.SC_OK);
		} catch (JSONException jsonex) {
		  throw new WebScriptException(Status.STATUS_INTERNAL_SERVER_ERROR,
		    "Error occurred while writing json response for changedType: " + changedType, jsonex);
		}
	}

	/**
	 * Gets the node ref.
	 *
	 * @param request the request
	 * @return the node ref
	 */
	private NodeRef getNodeRef(final WebScriptRequest request) {
	  final String storeType = getParam(request, STORE_TYPE);
	  final String storeId = getParam(request, STORE_ID);
	  final String identifier = getParam(request, PARAM_ID);
	  return new NodeRef(storeType, storeId, identifier);
	}

	/**
	 * Gets the param.
	 *
	 * @param request   the request
	 * @param paramName the param name
	 * @return the param
	 */
	private String getParam(final WebScriptRequest request, final String paramName) {
	  final String value = StringUtils.trimToNull(request.getServiceMatch().getTemplateVars().get(paramName));
	  if (StringUtils.isBlank(value)) {
	     throw new WebScriptException(Status.STATUS_BAD_REQUEST,
                    String.format("Value for param '%s' is missing, Please provide a valid input", paramName));
	  }
	  return value;
	}

	/**
	 * Write response.
	 * @param response           the response
	 * @param jsonObject         the json object
	 * @param clearCache         the clear cache
	 * @param responseStatusCode the response status code
	 * @throws IOException Signals that an I/O exception has occurred.
	 */
	private void writeResponse(final WebScriptResponse response, final JSONObject jsonObject, 
		final boolean clearCache, final int responseStatusCode) throws IOException {
	 final int length = jsonObject.toString().getBytes(ENCODING_UTF_8).length;
	 response.setContentType(MimetypeMap.MIMETYPE_JSON);
	 response.setContentEncoding(ENCODING_UTF_8);
	 response.addHeader(CONTENT_LENGTH, String.valueOf(length));
	 if (clearCache) {
	  response.addHeader(CACHE_CONTROL, NO_CACHE);
               //Calculate the expires date as per you need, i have kept a regular date for example purpose. 
	  response.addHeader(EXPIRES, "Thu, 04 Jan 2020 00:00:00 EDT");
	  response.addHeader(PRAGMA, NO_CACHE);
	 }
	  response.setStatus(responseStatusCode);
	  response.getWriter().write(jsonObject.toString());
	}
}
  • Add the following bean definition in *-context.xml (e.g. webscript-context.xml):
<bean id="webscript.org.alfresco.slingshot.documentlibrary.type.post"
		class="com.github.abhinavmishra14.webscript.ChangeTypeWebscript" parent="webscript">
    <constructor-arg ref="NamespaceService" />
    <constructor-arg ref="NodeService" />
  </bean>
  • To test whether change type is working or not, let's update the share-config-custom.xml file under
<config evaluator="string-compare" condition="DocumentLibrary"> to add type,sub-type config.
Add following config:
<config evaluator="string-compare" condition="DocumentLibrary">
	<aspects>
	  <visible/>
	  <addable/>
	  <removable/>
	</aspects>
	<types>
		<type name="cm:content">
		  <subtype name="demo:whitePaper"/>
		  <subtype name="demo:supportingDoc"/>
		</type>
		<type name="demo:whitePaper">
		  <subtype name="demo:supportingDoc"/>
		</type>
		<type name="demo:supportingDoc">
		  <subtype name="demo:whitePaper"/>
		</type>
	</types>
</config>
  •  Restart the repository and share. Select a file in your document library and click "Change Type" to use extended action.