Goal
This post is on developing a
Adobe CC HTML Extension to login to AEM 62,
search and download AEM assets (content fragments, images) and place them on a
new InDesign document page, effectively
creating PDF catalogs with the assets uploaded (and managed in AEM);
CC HTML Extensions run on
Common Extensibility Platform (CEP) of CC products like
InDesign, Photoshop, Illustrator...
Check this post for detailed instructions on how to create and debug CEP panels (MAC and Windows) and these
CEP samplesFor integrating
AEM with InDesign Server to extract media etc.
check documentation;
Catalog Producer to generate
product catalogs in AEM Assets is
documented hereSigning toolkit
ZXPSignCmd can be downloaded at
Adobe Labs (windows version available in the
extension source)
Download the
Extension Manager command line tool for
installing ZXP extensions (Adobe Extension Manager CC no longer supported in CC 2015)
Windows -
https://www.adobeexchange.com/ExManCmd_win.zip MAC -
https://www.adobeexchange.com/ExManCmd_mac.zipSome of the logic in this post was coded by me, some copied shamelessly (written by various Adobe colleagues)
Demo |
InDesign AEM ZXP |
AEM Sling Referrer Package Install |
Source CodeSling Referrer Filter "Allow Empty" set to "true" Configuration -
http://localhost:4502/system/console/configMgr/org.apache.sling.security.impl.ReferrerFilterZXP Installation Self-Signed Certificates - For dev purposes use
OpenSSL and generate a self-signed certificate for signing the ZXP
openssl req -x509 -days 3650 -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
Build - Use
ant to build the zxp using command >
ant zxp Install -
ExManCmd.exe /install "C:\dev\code\projects\cq62-extensions\eaem-aem-assets-on-indesign-page\temp\eaem-aem-assets-on-indesign-page.zxp" Installed to location (windows) -
C:\Program Files (x86)\Common Files\Adobe\CEP\extensions Remove -
ExManCmd.exe /remove com.experience.aem.cep.idsnPlace AEM Assets Panel - LoginPlace AEM Assets Panel - Search, Download and & Place Assets selected are downloaded to
C:\Users\<user>\Documents\eaem folder (created if not exists) on windows; The downloaded assets are placed on new InDesign page
Place AEM Assets Panel - Generate & Upload PDF PDF generated and saved to
C:\Users\<user>\Documents\eaem folder (created if not exists) on windows
PDF Catalog in AEMSolution
1) Folder structure:
For creating
InDesign CEP extensions (html panels), the following is a simple source code folder structure
eaem-aem-assets-on-indesign-page
css
style.css
CSXS
manifest.xml html
place-aem-assets.html
img
txt.png
js
aem-service.js
init-service.js
place-controller.js
CSInterface-5.2.js
jsx
place-assets.jsx
lib
exmancmd_win
experience-aem-cep.p12
ZXPSignCmd
.debug
build.xml
2) Debugging Panels:
a)
Debug url for the panel
Place AEM Assets as specified in
.debug file is
http://localhost:8098/ (available only when the panel is open in InDesign).
Check this post for detailed instructions on how to code & debug CEP panels (MAC and Windows)
<?xml version="1.0" encoding="UTF-8"?>
<ExtensionList>
<Extension Id="com.experience.aem.cep.idsn.place">
<HostList>
<Host Name="IDSN" Port="8098"/>
</HostList>
</Extension>
</ExtensionList>
b) Logging available in
C:\Users\<user>\AppData\Local\Temp\CEP6-IDSN.log.
Default is INFO, for debugging make sure you have the
PlayerDebugMode set to
1 and
LogLevel 4 (DEBUG) in
MAC -
/Users/<user>/Library/Preferences/com.adobe.CSXS.6.plist Windows registry key -
Computer\HKEY_CURRENT_USER\Software\Adobe\CSXS.6
c) ZXP when installed using
ExManCmd gets installed to
C:\Program Files (x86)\Common Files\Adobe\CEP\extensions (Windows)
3)
CSXS/manifest.xml file is required for every extension and provides necessary configuration information for the extension
<Resources>
<MainPath>./html/place-aem-assets.html</MainPath>
<ScriptPath>./jsx/place-assets.jsx</ScriptPath>
<CEFCommandLine>
<Parameter>--enable-nodejs</Parameter>
<Parameter>--mixed-context</Parameter>
</CEFCommandLine>
</Resources>
a) #2 specifies the
home page/splash screen of the CEP html extension
b) #3 contains the path to file containing
extendscript logic for interacting with the host, here InDesign (to create document and place assets on page)
c) #4 to #7 direct CEP engine to make
nodejs module available for the extension panel (used for
upload/download of relatively large assets in chunks eg. to download a 1 GB image from AEM)
4)
Place AEM Assets extension is a SPA (Single Page Application) using
angularjs for building the application and give it dynamic behavior, so the single html page
place-aem-assets.html consists of angular directives and controller to provide
login screen, show search page etc.
<!DOCTYPE html>
<html lang="en" ng-app="SearchAEM">
<head>
<meta charset="utf-8">
<title>Place AEM Assets</title>
<link rel="stylesheet" href="../css/style.css">
</head>
<body>
<div ng-controller="placeController">
<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">
<div class="top-left">
<input type="text" placeholder="AEM PDF Upload Path" ng-model="uploadPath"/>
<input type="text" placeholder="Search text" ng-model="term" ng-enter="search()"/>
</div>
<div class="top-right">
<button ng-click="search()">Search</button>
</div>
</div>
<div class="results">
<div class="result-block" ng-class="{ selected : result.selected } "
ng-repeat="result in results" ng-click="select(result)">
<div>
<img ng-src="{{result.imgPath}}"/>
</div>
<div>
{{result.name}}
</div>
</div>
</div>
<div class="bottom">
<div class="bottom-left" ng-show="results.length > 0">
<button ng-click="place()">Place</button>
</div>
<div class="bottom-right">
<button ng-click="generatePDFAndUpload()">Generate PDF & Upload</button>
</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/init-service.js"></script>
<script src="../js/aem-service.js"></script>
<script src="../js/place-controller.js"></script>
</body>
</html>
5) The necessary modules and services are defined in
js/init-service.js'use strict';
(function () {
var underscore = angular.module('underscore', []);
underscore.factory('_', function () {
return window._;
});
var cep = angular.module('cep', []);
cep.service('csi', CSInterface);
cep.factory('cep', ['$window', function ($window) {
return $window.cep;
}]);
cep.factory('fs', ['cep', function (cep) {
return cep.fs;
}]);
cep.factory('nfs', ['cep', function (cep) {
return require("fs");
}]);
cep.factory('nhttp', function () {
return require("http");
});
cep.factory('nqs', function () {
return require('querystring')
});
cep.factory('nbuffer', function () {
return require('buffer');
});
}());
6)
js/aem-service.js defines all the necessary services and functions for interacting with AEM (login, search, download, upload etc.)
a) Use
CSInterface.evalScript() function to execute extend script functions in the host engine (InDesign) eg. #42
EAEM.placeAssets(), #59
EAEM.exportAsPDF() ES functions
b) #95
loginWithNJS() for
AEM login, read the
login_token from cookies (#122), get the
csrfToken (#132) and make them available for other services (search, download, upload etc)
by setting in angular
$rootScope (#130)
c) #159
downloadWithNJS() for
downloading large files in chunks (tested with files upto 10GB)
d) #220
uploadWithNJS() to upload generated PDFs
in 5MB chunks (utilizing
AEM chunk upload feature)
e) #307
SearchService() with necessary functions to search for assets in AEM using
Query Builder'use strict';
(function () {
var aem = angular.module('aem', ['underscore', 'cep']);
aem.service('aemService', AEMService);
aem.factory('searchService', SearchService);
AEMService.$inject = [ '_', 'csi', 'fs', '$http', '$rootScope' ,'nhttp', 'nqs', 'nfs', 'nbuffer', '$q' ];
function AEMService(_, csi, fs, $http, $rootScope, nhttp, nqs, nfs, nbuffer, $q){
return {
getFilename: getFilename,
loginWithNJS: loginWithNJS,
appendLoginToken: appendLoginToken,
getDownloadPath: getDownloadPath,
downloadWithNJS: downloadWithNJS,
uploadWithNJS: uploadWithNJS,
placeAssets: placeAssets,
generatePDF: generatePDF
};
function isCEPError(error){
return _.isEmpty(error) || (error.toUpperCase() == "ERROR");
}
function placeAssets(filePaths) {
if (_.isEmpty(filePaths)) {
return $q.when({});
}
var deferred = $q.defer();
function handler(result) {
if (isCEPError(result)) {
deferred.reject("Error placing assets");
return;
}
deferred.resolve(result);
}
csi.evalScript("EAEM.placeAssets('" + _.values(filePaths).join(",") + "')", handler);
return deferred.promise;
}
function generatePDF() {
var deferred = $q.defer();
function handler(result) {
if (isCEPError(result)) {
deferred.reject("Error generating PDF");
return;
}
deferred.resolve(result);
}
csi.evalScript("EAEM.exportAsPDF()", handler);
return deferred.promise;
}
function getFilename(path) {
path = path ? path.replace(/\\/g, '/') : '';
return path.substring(path.lastIndexOf('/') + 1);
}
function getDownloadPath(){
var folderPath = csi.getSystemPath(SystemPath.MY_DOCUMENTS) + "/eaem";
fs.makedir(folderPath);
return folderPath;
}
function getHostPort(host){
var arr = [];
if(host.indexOf("/") >= 0){
host = host.substring(host.lastIndexOf("/") + 1);
}
if(host.indexOf(":") < 0){
arr.push(host);
arr.push("80")
}else{
arr.push(host.split(":")[0]);
arr.push(host.split(":")[1]);
}
return arr;
}
//login with nodejs to capture login cookie
function loginWithNJS(username, password, damHost){
var hp = getHostPort(damHost);
if(!_.isEmpty($rootScope.dam)){
return $q.when($rootScope.dam);
}
var deferred = $q.defer(), dam = { host : damHost };
var options = {
hostname: hp[0],
port: hp[1],
path: "/libs/granite/core/content/login.html/j_security_check",
headers: {
'Content-type': 'application/x-www-form-urlencoded'
},
method: 'POST'
};
var req = nhttp.request(options, function(res) {
var cookies = res.headers["set-cookie"];
_.each(cookies, function(cookie){
if(cookie.indexOf("login-token") == -1){
return;
}
dam.loginToken = cookie.split('login-token=')[1];
});
if(_.isEmpty(dam.loginToken)){
deferred.reject("Trouble logging-in, Invalid Credentials?");
return;
}
$rootScope.dam = dam;
$http.get( appendLoginToken(dam.host + '/libs/granite/csrf/token.json')).then(function(data){
dam.csrfToken = data.data.token;
deferred.resolve(dam);
})
});
req.on('error', function(e) {
deferred.reject("Trouble logging-in, Invalid Credentials?");
});
var data = nqs.stringify({
_charset_: "UTF-8",
j_username: username,
j_password: password,
j_validate: true
});
req.write(data);
req.end();
return deferred.promise;
}
function appendLoginToken(url){
return url + (url.indexOf("?") == -1 ? "?" : "&") + "j_login_token=" + $rootScope.dam.loginToken;
}
function downloadWithNJS(damPaths){
if(_.isEmpty(damPaths)){
return $q.when({});
}
damPaths = _.uniq(damPaths);
var deferred = $q.defer(), filePaths = {},
count = damPaths.length, dam = $rootScope.dam;
_.each(damPaths, handler);
return deferred.promise;
function handler(damPath){
damPath = decodeURIComponent(damPath);
var url = appendLoginToken(dam.host + damPath),
filePath = getDownloadPath() + "/" + getFilename(damPath);
if (nfs.existsSync(filePath)) {
nfs.unlinkSync(filePath);
}
var file = nfs.openSync(filePath, 'w');
var req = nhttp.get(url, function(res) {
if(res.statusCode == 404){
handle404(damPath);
return;
}
res.on('data', function(chunk) {
nfs.writeSync(file, chunk, 0, chunk.length);
});
res.on('end', function() {
nfs.closeSync(file);
count--;
filePaths[damPath] = filePath;
if(count != 0){
return;
}
deferred.resolve(filePaths);
});
});
req.on('error', function(e) {
deferred.reject("Error downloading file");
});
}
function handle404(damPath){
alert("Asset Not Found - " + damPath);
}
}
function uploadWithNJS(localPath, damFolderPath){
if(_.isEmpty(localPath) || _.isEmpty(damFolderPath)){
return $q.when( { "error" : "Empty paths"} );
}
var BUFFER_SIZE = 5 * 1024 * 1024, // 5MB
dam = $rootScope.dam,
uploadPath = appendLoginToken(dam.host + damFolderPath + ".createasset.html"),
file = nfs.openSync(localPath, 'r'),
deferred = $q.defer();
readNextBytes(0);
return deferred.promise;
function readNextBytes(offset){
var buffer = nbuffer.Buffer(BUFFER_SIZE, 'base64'),
bytes = nfs.readSync(file, buffer, 0, BUFFER_SIZE, null),
complete = false;
if (bytes < BUFFER_SIZE) {
buffer = buffer.slice(0, bytes);
complete = true;
}
uploadBlob(getBlob(buffer), offset, complete);
}
function uploadBlob(blob, offset, complete) {
var fd = new FormData();
fd.append('file', blob);
fd.append("fileName", getFilename(localPath));
fd.append("file@Offset", offset);
fd.append("file@Length", 0);
fd.append("file@Completed", complete);
fd.append("_charset_", "utf-8");
return $http.post(uploadPath, fd, {
transformRequest: angular.identity, //no transformation return data as-is
headers: {
'CSRF-Token' : dam.csrfToken,
'Content-Type': undefined //determine based on file type
}
}).then(function () {
if (complete) {
nfs.closeSync(file);
deferred.resolve(damFolderPath + "/" + getFilename(localPath));
return;
}
readNextBytes(offset + BUFFER_SIZE);
}, failure);
function failure() {
nfs.closeSync(file);
alert("Error upoading");
}
}
function getBlob(fileOrBytes){
var bytes = fileOrBytes.data ? atob(decodeURIComponent(escape(fileOrBytes.data)).replace(/\s/g, ''))
: fileOrBytes;
var bArrays = [];
var SLICE_LEN = 1024, end, slice, nums;
for (var offset = 0; offset < bytes.length; offset = offset + SLICE_LEN) {
end = offset + SLICE_LEN;
slice = bytes.slice(offset, end < bytes.length ? end : bytes.length);
nums = new Array(slice.length);
for (var i = 0; i < slice.length; i++) {
nums[i] = fileOrBytes.data ? slice.charCodeAt(i) : slice[i];
}
bArrays.push(new Uint8Array(nums));
}
return new Blob(bArrays, {
type: "application/octet-binary"
});
}
}
}
SearchService.$inject = [ '_', '$http', '$rootScope' ];
function SearchService(_, $http, $rootScope){
return function (defaults) {
this.aem = "http://localhost:4502";
this.params = _.extend( { j_login_token : $rootScope.dam.loginToken }, 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
});
}
}
}
}());
7) Angular controller
placeController for binding the logic to html page is defined in
js/place-controller.js
'use strict';
(function () {
var app = angular.module('SearchAEM', ['aem']);
app.directive('ngEnter', ngEnterFn);
app.controller('placeController', PlaceController);
function ngEnterFn(){
return function(scope, element, attrs) {
element.bind("keydown keypress", function(event) {
if (event.which === 13) {
scope.$apply(function() {
scope.$eval(attrs.ngEnter);
});
event.preventDefault();
}
});
};
}
PlaceController.$inject = [ '$scope', 'aemService', 'searchService', '$http', 'csi', 'cep' ];
function PlaceController($scope, aemService, searchService, $http, csi, cep){
$scope.damHost = "localhost:4502";
$scope.showLogin = true;
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.login = login;
$scope.search = search;
$scope.select = select;
$scope.place = place;
$scope.generatePDFAndUpload = generatePDFAndUpload;
function login() {
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;
}
function success(){
$scope.showLogin = false;
}
function error(message){
alert(message);
}
aemService.loginWithNJS($scope.j_username, $scope.j_password, $scope.damHost)
.then(success, error);
}
function search(){
if (!$scope.term) {
alert("Enter search term");
return;
}
$scope.results = [];
var mapHit = function(hit) {
var result;
result = {};
result.selected = false;
result.name = aemService.getFilename(hit["jcr:path"]);
result.path = hit["jcr:path"];
result.imgPath = aemService.appendLoginToken($scope.damHost + hit["jcr:path"]
+ "/jcr:content/renditions/cq5dam.thumbnail.140.100.png");
result.format = hit["jcr:content"]["metadata"]["dc:format"];
if(result.format == "text/html"){
result.imgPath = "../img/txt.png";
}
return result;
};
new searchService(searchDefaults).host($scope.damHost)
.fullText($scope.term)
.http()
.then(function(resp) {
$scope.results = _.compact(_.map(resp.data.hits, mapHit));
});
}
function select(result){
result.selected = !result.selected;
}
function place(){
var toDownload = _.reject($scope.results, function(result){
return !result.selected;
});
aemService.downloadWithNJS(_.pluck(toDownload, 'path'))
.then(setDownloadedPaths)
.then(aemService.placeAssets)
.then(aemService.uploadWithNJS)
}
function setDownloadedPaths(filePaths){
_.each($scope.results, function(result){
result.localPath = filePaths[result.path] || '';
});
return filePaths;
}
function generatePDFAndUpload(){
if(_.isEmpty($scope.uploadPath)){
alert("Enter PDF upload location in AEM");
return;
}
aemService.generatePDF()
.then(upload)
.then(function(damFilePath){
alert("Uploaded - " + damFilePath);
});
function upload(pdfPath){
return aemService.uploadWithNJS(pdfPath, $scope.uploadPath);
}
}
}
}());
8)
InDesign extend script logic for creating a new document and place the downloaded assets on pages is defined in
jsx/place-assets.jsx(function () {
if (typeof EAEM == "undefined") {
EAEM = {
COLUMNS_PER_SPREAD: 3,
ROWS_PER_SPREAD: 2
};
}
function collectionToArray(theCollection) {
return (theCollection instanceof Array) ? theCollection.slice(0)
: theCollection.everyItem().getElements().slice(0);
}
function getContainerAssetCount(spreadOrGroup){
var pageItems = collectionToArray(spreadOrGroup.pageItems),
count = 0;
for (var pageItemIdx = 0; pageItemIdx < pageItems.length; pageItemIdx++) {
var pageItem = pageItems[pageItemIdx];
if (pageItem instanceof Group) {
count = count + getContainerAssetCount(pageItem);
}else {
count++;
}
}
return count;
}
function getPlaceSpread(document){
var lastSpread = document.spreads.lastItem();
var count = getContainerAssetCount(lastSpread),
spread;
if (count < (EAEM.COLUMNS_PER_SPREAD * EAEM.ROWS_PER_SPREAD)) {
spread = lastSpread;
}else{
spread = document.spreads.add();
}
return spread;
}
function getNextGridPos(spread) {
var gridPos = {
row: 0,
column: 0
};
var count = getContainerAssetCount(spread);
if(count > 0){
gridPos.row = Math.floor(count / EAEM.COLUMNS_PER_SPREAD);
gridPos.column = count % EAEM.COLUMNS_PER_SPREAD;
}
return gridPos;
}
function createPageItem(spread) {
var rect = spread.textFrames.add();
var y1 = 0; // upper left Y-Coordinate
var x1 = 0; // upper left X-Coordinate
var y2 = 275; // lower right Y-Coordinate
var x2 = 160; // lower right X-Coordinate
rect.geometricBounds = [ y1 , x1 , y2 , x2 ];
return rect;
}
function movePageItem(document, spread, gridPos, rect){
var marginTop = document.marginPreferences.top;
var marginBottom = document.marginPreferences.bottom;
var marginLeft = document.marginPreferences.left;
var marginRight = document.marginPreferences.right;
var spreadLeftTop = spread.pages.firstItem().resolve(
AnchorPoint.TOP_LEFT_ANCHOR, CoordinateSpaces.SPREAD_COORDINATES)[0];
var spreadRightBottom = spread.pages.lastItem().resolve(
AnchorPoint.BOTTOM_RIGHT_ANCHOR, CoordinateSpaces.SPREAD_COORDINATES)[0];
var spreadWidth = spreadRightBottom[0] - spreadLeftTop[0] - marginLeft - marginRight;
var spreadHeight = spreadRightBottom[1] - spreadLeftTop[1] - marginTop - marginBottom;
var stepH = spreadWidth / EAEM.COLUMNS_PER_SPREAD;
var stepV = spreadHeight / EAEM.ROWS_PER_SPREAD;
var xPos = spreadLeftTop[0] + gridPos.column * stepH + marginLeft + 10;
var yPos = spreadLeftTop[1] + gridPos.row * stepV + marginTop + 25;
var rectTop = rect.resolve(AnchorPoint.TOP_LEFT_ANCHOR, CoordinateSpaces.SPREAD_COORDINATES)[0];
var deltaX = xPos - rectTop[0];
var deltaY = yPos - rectTop[1];
rect.move(null,[deltaX, deltaY]);
}
function placeImage(rect, pdfPath){
rect.contents = "";
rect.contentType = ContentType.UNASSIGNED;
rect.place(pdfPath);
rect.fit(FitOptions.PROPORTIONALLY);
}
EAEM.placeAssets = function(commaSepPaths){
var result = "ERROR", document,
units = app.scriptPreferences.measurementUnit;
try{
app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
if(app.documents.length == 0){
document = app.documents.add();
}else{
document = app.activeDocument;
}
var assetsArray = commaSepPaths.split(",");
for(var i = 0; i < assetsArray.length; i++){
var spread = getPlaceSpread(document);
var gridPos = getNextGridPos(spread);
var rect = createPageItem(spread);
movePageItem(document, spread, gridPos, rect);
placeImage(rect, assetsArray[i]);
}
result = "SUCCESS";
}catch(err){
result = "ERROR";
}
app.scriptPreferences.measurementUnit = units;
return result;
};
EAEM.exportAsPDF = function(){
var document, result = "ERROR";
try{
if(app.documents.length == 0){
document = app.documents.add();
}else{
document = app.activeDocument;
}
var pdfPath = Folder.myDocuments.fsName.replace(/\\/g, '/') + "/eaem/" + document.name + ".pdf";
document.exportFile(ExportFormat.pdfType, new File(pdfPath), false,
app.pdfExportPresets.item("[High Quality Print]"));
result = pdfPath;
}catch(err){
result = "ERROR";
}
return result;
};
})();