AEM Tool – Perfect Content backup Package in AEM

Problem statement:

Create a perfect content backup package in AEM, whenever we want to create any package in AEM we provide the content paths in the package filter manually and what do we get?

  1. We get only content pages.
  2. What about images and referenced pages?
  3. What about header/footer experience fragment pages or XF page-related context hub variations?

Introduction:

AEM OOTB comes with multiple tools in AEM and to access all the tools you need to navigate to the tool section and select the appropriate sections to perform all the operations

For example:

  1. AEM operations
  2. Managing templates
  3. Cloud configurations
  4. ACS Commons tools etc..

Tools are an essential part of AEM and avoid any dependency on Groovy scripts or any add scripts and can be managed or extended at any given point in time.

Perfect packager pulls the current page resource and iterates on each node and gets the referenced images, pages, and XF’s. Once all the paths are pulled it reiterates to find all the referenced pages to get all the images, XF’s, and pages.

In order to create a perfect content backup package please use the Tool generator to give your tool name and descriptions: https://kiransg.com/2022/11/24/aem-tool-create-generate-tool-from-scratch

Add the following Sling model fields as shown below to accept the inputs:

package com.aem.operations.core.models;

import javax.inject.Named;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import com.adobe.acs.commons.mcp.form.FormField;
import com.adobe.acs.commons.mcp.form.GeneratedDialog;
import lombok.Getter;
import lombok.Setter;

@Model(adaptables = { Resource.class,SlingHttpServletRequest.class }, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class PerfectPackageModel extends GeneratedDialog {

    @Getter
    @Setter
    @Named(value = "packageName")
    @FormField(
            name = "Package Name",
            category = "General",
            required = true,
            description = "Enter the package name",
            hint = "Name")
    private String packageName;

    @Getter
    @Setter
    @Named(value = "packageDescription")
    @FormField(
            name = "Package Description",
            category = "General",
            required = true,
            description = "Enter the package Description",
            hint = "Description")
    private String packageDescription;
}

Add the following Servlet to process the request as shown below:

package com.aem.operations.core.servlets;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import com.aem.operations.core.utils.ReportRetryUtils;
import com.aem.operations.core.visitors.ContentVisitor;
import org.apache.commons.lang3.StringUtils;
import org.apache.jackrabbit.vault.fs.io.AccessControlHandling;
import org.apache.jackrabbit.vault.packaging.JcrPackage;
import org.apache.jackrabbit.vault.packaging.JcrPackageDefinition;
import org.apache.jackrabbit.vault.packaging.JcrPackageManager;
import org.apache.jackrabbit.vault.packaging.PackageException;
import org.apache.jackrabbit.vault.packaging.Packaging;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.servlets.post.JSONResponse;
import org.jetbrains.annotations.NotNull;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.adobe.acs.commons.packaging.PackageHelper;
import com.adobe.granite.rest.Constants;
import com.google.gson.Gson;
import com.google.gson.JsonObject;

@Component(
        service = { Servlet.class },
        property = {
                "sling.servlet.paths=" + PerfectPackageServlet.RESOURCE_PATH,
                "sling.servlet.methods=POST"
        }
)
public class PerfectPackageServlet extends SlingAllMethodsServlet {

    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(PerfectPackageServlet.class);
    public static final String RESOURCE_PATH = "/bin/triggerPerfectPackage";
    private static final String MESSAGE = "message";

    private static final String DEFUALT_GROUP_NAME = "my_packages";
    private static final String DEFUALT_VERSION = "1.0";
    private static final String QUERY_PACKAGE_THUMBNAIL_RESOURCE_PATH = "/apps/acs-commons/components/utilities/packager/query-packager/definition/package-thumbnail.png";

    @Reference
    private PackageHelper packageHelper;

    @Reference
    private Packaging packaging;

    @Override
    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException {
        // Set response headers.
        response.setContentType(JSONResponse.RESPONSE_CONTENT_TYPE);
        response.setCharacterEncoding(Constants.DEFAULT_CHARSET);

        JsonObject jsonResponse = new JsonObject();

        String packageName = request.getParameter("packageName");
        String packageDescription = request.getParameter("packageGroup");
        String pathsList = request.getParameter("pathsList");

        String packagePath = StringUtils.EMPTY;
        if(StringUtils.isNotEmpty(pathsList)) {
            packagePath = collectReferences(request.getResourceResolver(), pathsList, packageName, packageDescription);
        }
        if(StringUtils.isNotEmpty(packagePath)) {
            jsonResponse.addProperty(MESSAGE, "Package creation is Successful and path is <a href=\"" + packagePath + "\">" + packagePath + "</a>");
        } else {
            LOGGER.error("Unable to process package creation");
        }
        try (PrintWriter out = response.getWriter()) {
            out.print(new Gson().toJson(jsonResponse));
        }
    }

    private String collectReferences(ResourceResolver resourceResolver, String pagePaths, String packageName, String packageDescription) {
        Set<String> contentPaths = new HashSet<>();
        Set<String> xfPaths = new HashSet<>();
        Set<String> damPaths = new HashSet<>();
        ContentVisitor contentVisitor =  new ContentVisitor();
        Set<String> pages = new HashSet<>(Arrays.asList(StringUtils.split(pagePaths, '\n'))).stream().map(r -> r.trim() + "/jcr:content").collect(Collectors.toSet());
        try {
            iterateContentPaths(resourceResolver, contentPaths, xfPaths, damPaths, contentVisitor, pages);
            damPaths.addAll(iterateContent(resourceResolver, contentVisitor, damPaths));
            ReportRetryUtils.withRetry(2, 300, () -> xfPaths.addAll(iterateContent(resourceResolver, contentVisitor, xfPaths)));
            Set<String> allPaths = Stream.of(pages, contentPaths, xfPaths, damPaths).flatMap(Collection::stream).collect(Collectors.toSet());
            return packagePaths(resourceResolver, allPaths, packageName, packageDescription);
        } catch (Exception e) {
            LOGGER.error("Exception occurred during package creation in Retry {}", e.getMessage());
        }
        return StringUtils.EMPTY;
    }

    private Set<String> iterateContent(ResourceResolver resourceResolver, ContentVisitor contentVisitor, Set<String> pages) {
        Set<String> interimPaths = new HashSet<>();
        for(String page : pages) {
            contentVisitor.accept(resourceResolver.resolve(page));
            interimPaths.addAll(contentVisitor.getContentPaths());
            interimPaths.addAll(contentVisitor.getDamPaths());
            interimPaths.addAll(contentVisitor.getXfPaths());
        }
        return interimPaths;
    }

    private void iterateContentPaths(ResourceResolver resourceResolver, Set<String> contentPaths, Set<String> xfPaths,
                                     Set<String> damPaths, ContentVisitor contentVisitor, Set<String> pages) {
        for(String page : pages) {
            contentVisitor.accept(resourceResolver.resolve(page));
            contentPaths.addAll(contentVisitor.getContentPaths());
            xfPaths.addAll(contentVisitor.getXfPaths());
            damPaths.addAll(contentVisitor.getDamPaths());
        }
    }

    private String packagePaths(ResourceResolver resourceResolver, Set<String> allPaths, String packageName, String packageDescription)
            throws IOException, RepositoryException, PackageException {
        Map<String, String> packageDefinitionProperties = new HashMap<>();
        // ACL Handling
        packageDefinitionProperties.put(JcrPackageDefinition.PN_AC_HANDLING,
                AccessControlHandling.OVERWRITE.toString());

        // Package Description
        packageDefinitionProperties.put(JcrPackageDefinition.PN_DESCRIPTION, packageDescription);
        Set<@NotNull Resource> packageResources = allPaths.stream().map(resourceResolver::resolve).collect(Collectors.toSet());
        try(JcrPackage jcrPackage = packageHelper.createPackage(packageResources,
                resourceResolver.adaptTo(Session.class), DEFUALT_GROUP_NAME, packageName, DEFUALT_VERSION,
                PackageHelper.ConflictResolution.Replace, packageDefinitionProperties)){
            // Add thumbnail to the package definition
            packageHelper.addThumbnail(jcrPackage, resourceResolver.getResource(QUERY_PACKAGE_THUMBNAIL_RESOURCE_PATH));
            final JcrPackageManager packageManager = packaging.getPackageManager(resourceResolver.adaptTo(Session.class));
            packageManager.assemble(jcrPackage, null);
            return jcrPackage.getNode().getPath();
        }
    }
}

Add the following Content Visitor class to iterate resources as shown below:

package com.aem.operations.core.visitors;

import java.util.Arrays;
import java.util.HashSet;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.regex.Pattern;
import org.apache.commons.lang3.StringUtils;
import org.apache.sling.api.resource.AbstractResourceVisitor;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.resource.ResourceUtil;
import org.apache.sling.api.resource.ValueMap;
import org.jetbrains.annotations.NotNull;

public class ContentVisitor extends AbstractResourceVisitor {

    private static final String CONTENT_SLASH = "/content/";
    private static final String CONTENT_DAM_SLASH = "/content/dam/";
    private static final String CONTENT_XF_SLASH = "/content/experience-fragments/";

    Pattern htmlPattern = Pattern.compile(".*\\<[^>]+>.*", Pattern.DOTALL);

    Set<String> contentPaths = new HashSet<>();
    Set<String> xfPaths = new HashSet<>();
    Set<String> damPaths = new HashSet<>();

    @Override
    public final void accept(final Resource resource) {
        if (null != resource && !ResourceUtil.isNonExistingResource(resource)) {
            final ValueMap properties = resource.adaptTo(ValueMap.class);
            final String primaryType = properties.get(ResourceResolver.PROPERTY_RESOURCE_TYPE, StringUtils.EMPTY);
            if(StringUtils.isNoneEmpty(primaryType) || StringUtils.startsWith(resource.getPath(), "/content/dam/content-fragments")){
                visit(resource);
            }
            this.traverseChildren(resource.listChildren());
        }
    }

    @Override
    protected void traverseChildren(final @NotNull Iterator<Resource> children) {
        while (children.hasNext()) {
            final Resource child = children.next();
            accept(child);
        }
    }

    @Override
    protected void visit(final @NotNull Resource resource) {
        resource.getValueMap().entrySet().forEach(property -> {
            Object prop = property.getValue();
            if (prop.getClass() == String[].class) {
                List<String> propertyValue = Arrays.asList((String[]) prop);
                if (!propertyValue.isEmpty()) {
                    propertyValue.stream().filter(s -> StringUtils.isNotEmpty(s) && StringUtils.startsWith(s, CONTENT_SLASH) && !htmlPattern.matcher(s).matches()).forEach(this::populateValue);
                }
            } else if (prop.getClass() == String.class) {
                String propertyValue = (String) prop;
                if (StringUtils.isNotEmpty(propertyValue) && StringUtils.startsWith(propertyValue, CONTENT_SLASH) && !htmlPattern.matcher(propertyValue).matches()) {
                    populateValue(propertyValue);
                }
            }
        });
    }

    private void populateValue(String value) {
        if(StringUtils.startsWith(value, CONTENT_DAM_SLASH)) {
            damPaths.add(value);
        } else if(StringUtils.startsWith(value, CONTENT_XF_SLASH)) {
            xfPaths.add(value+"/jcr:content");
        } else {
            contentPaths.add(StringUtils.substringBeforeLast(value, ".html")+"/jcr:content");
        }
    }

    public Set<String> getContentPaths() {
        return contentPaths;
    }

    public Set<String> getXfPaths() {
        return xfPaths;
    }

    public Set<String> getDamPaths() {
        return damPaths;
    }
}

Add the following Report Retry Utils as shown below:

package com.aem.operations.core.utils;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ReportRetryUtils {
    private static final Logger log = LoggerFactory.getLogger(ReportRetryUtils.class);

    public static interface CallToRetry {
        void process() throws Exception;
    }

    public static boolean withRetry(int maxTimes, long intervalWait, CallToRetry call) throws Exception {
        if (maxTimes <= 0) {
            throw new IllegalArgumentException("Must run at least one time");
        }
        if (intervalWait <= 0) {
            throw new IllegalArgumentException("Initial wait must be at least 1");
        }
        Exception thrown = null;
        for (int i = 0; i < maxTimes; i++) {
            try {
                call.process();
                if(i == (maxTimes-1)) {
                    return true;
                }
            } catch (Exception e) {
                thrown = e;
                log.info("Encountered failure on {} due to {}, attempt retry {} of {}", call.getClass().getName() , e.getMessage(), (i + 1), maxTimes, e);
            }
            try {
                Thread.sleep(intervalWait);
            } catch (InterruptedException wakeAndAbort) {
                break;
            }
        }
        throw thrown;
    }
}

Add the following HTL to render the page:

<!DOCTYPE html>
<head>
   <link rel="shortcut icon" href="/libs/granite/core/content/login/favicon.ico">
</head>
<body class="coral--light foundation-layout-util-maximized-alt">
   <sly data-sly-call="${clientLib.all @ categories=['coralui3','granite.ui.coral.foundation','granite.ui.shell','cq.authoring.dialog', 'perfectpackage.base']}"
      data-sly-use.clientLib="/libs/granite/sightly/templates/clientlib.html"/>
   <coral-shell-header aria-hidden="false" aria-label="Header Bar"
      class="coral--dark granite-shell-header coral3-Shell-header"
      role="region">
      <coral-shell-header-home aria-level="2" class="globalnav-toggle" data-globalnav-toggle-href="/" role="heading">
         <a class="coral3-Shell-homeAnchor" href="/"
            icon="adobeExperienceManagerColor" is="coral-shell-homeanchor"
            style="display: inline-block; padding-right: 0;">
            <coral-icon
               aria-label="adobe experience manager color"
               class="coral3-Icon coral3-Shell-homeAnchor-icon coral3-Icon--adobeExperienceManagerColor coral3-Icon--sizeM"
               icon="adobeExperienceManagerColor" role="img"
               size="M"></coral-icon>
            <coral-shell-homeanchor-label>Adobe Experience Manager
            </coral-shell-homeanchor-label>
         </a>
         <span style="line-height: 2.375rem;">/ AEM Operations / ${properties.jcr:title} </span>
      </coral-shell-header-home>
   </coral-shell-header>
   <div class="foundation-layout-panel">
      <div class="foundation-layout-panel-header">
         <betty-titlebar>
            <betty-titlebar-title>
               <span aria-level="1" class="granite-title" role="heading">${properties.jcr:title}</span>
            </betty-titlebar-title>
            <betty-titlebar-primary></betty-titlebar-primary>
            <betty-titlebar-secondary>
            </betty-titlebar-secondary>
         </betty-titlebar>
      </div>
      <div class="content-container-inner coral-Well" style="width:60%; margin:0 auto;">
         <div class="acsCommons-System-Notifications-Form-row">
		   <label class="coral-Form-fieldlabel">List of Paths</label>
		   <label> Note: Add the list of paths has a Line feed (No need for comma separation)</label>
		   <textarea
		      class="coral-Textfield coral-Textfield--multiline acsCommons-System-Notifications-Page-input--textarea"
		      rows="6"
		      name="./pathsList"></textarea>
		</div>
         <sly data-sly-use.packprops="com.aem.operations.core.models.PerfectPackageModel">
            <sly data-sly-resource="${packprops.formResource}"/>
         </sly>
         <coral-actionbar-item class="coral3-ActionBar-item">
            <button class="coral3-Button coral3-Button--primary package-initiator" icon="checkCircle" iconsize="S"
               is="coral-button"
               size="M" variant="primary">
               <coral-icon alt="" class="coral3-Icon coral3-Icon--sizeS coral3-Icon--checkCircle" icon="folderGear"
                  size="S"></coral-icon>
               <coral-button-label>
                  Create Content Backup
               </coral-button-label>
            </button>
         </coral-actionbar-item>
          <!-- The Modal -->
          <div id="popup-modal" class="modal">
              <!-- Modal content -->
              <div class="modal-content">
                  <span class="close">&times;</span>
                  <span class="loading">Loading</span>
                  <span class="loader"></span>
              </div>
          </div>
      </div>
   </div>
</body>

Add the following JS code to process the ajax request as shown below:

var Coral = window.Coral || {},
    Granite = window.Granite || {};

(function (window, document, $, Coral) {
    "use strict";
    $(document).on("foundation-contentloaded", function (e) {

        var SITE_PATH = "/conf/aemoperations/settings/tools/perfectpackage-initiator.html",
            ui = $(window).adaptTo("foundation-ui");

        if (window.location.href.indexOf(SITE_PATH) < 0) {
            return;
        }

        $(document).off("change", ".close").on("change", ".close", function (event) {
            $(".modal").hide();
        });

        function getValueByName(fieldName, isMandatory) {
            var fieldValue = ($("input[name='" + fieldName + "']").val()).trim();
            if (!isMandatory) {
                return fieldValue;
            }
            if (!fieldValue || fieldValue.length === 0) {
                //for input fields
                $("input[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("input[name='" + fieldName + "']").attr('invalid', 'invalid');

                //for select fields
                $("coral-select[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("coral-select[name='" + fieldName + "']").attr('invalid', 'invalid');

                return;
            } else {
                return fieldValue;
            }
        }

        function getValueByNameArea(fieldName, isMandatory) {
            var fieldValue = ($("textarea[name='" + fieldName + "']").val()).trim();
            if (!isMandatory) {
                return fieldValue;
            }
            if (!fieldValue || fieldValue.length === 0) {
                //for input fields
                $("textarea[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("textarea[name='" + fieldName + "']").attr('invalid', 'invalid');

                //for select fields
                $("coral-select[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("coral-select[name='" + fieldName + "']").attr('invalid', 'invalid');

                return;
            } else {
                return fieldValue;
            }
        }

        $(document).off("click", ".package-initiator").on("click", ".package-initiator", function (event) {
            event.preventDefault();

            var packageName = getValueByName('./packageName', true),
                packageDescription = getValueByName('./packageDescription', true),
                pathsList = getValueByNameArea('./pathsList', true);

            var formData = new FormData();
            formData.append("packageName", packageName);
            formData.append("packageDescription", packageDescription);
            formData.append("pathsList", pathsList);

			$(".loading").html("PLEASE WAIT CREATINGING PACKAGE");
            $(".modal").show();

            $.ajax({
                url: "/bin/triggerPerfectPackage",
                method: "POST",
                async: true,
                cache: false,
                contentType: false,
                processData: false,
                data: formData
            }).done(function (data) {
                if (data && data.message){
                    ui.notify("Success", data.message, "success");
                    var dialog = new Coral.Dialog();
                    dialog.id = 'dialogSuccess';
                    dialog.header.innerHTML = 'Success';
                    dialog.content.innerHTML = data.message;
                    dialog.footer.innerHTML = '<button class="ok-button" is="coral-button" variant="primary" icon="check" coral-close>OK</button>';
                    dialog.variant = 'success';
                    dialog.closable = "on";
                    dialog.show();
                    $(".modal").hide();
                }else{
                    ui.notify("Error", "Unable to create package", "error");
                }
            }).fail(function (data) {
				$(".modal").hide();
                if (data && data.responseJSON && data.responseJSON.message){
                    ui.notify("Error", data.responseJSON.message, "error");
                }else{
                    ui.notify("Error", "Unable to create package", "error");
                }
            });
        });
    });
})(window, document, $, Coral);

Once the code is built and deployed you will be able to access the tool by navigating the:

Tools -> {Tool Section} -> {Tool Name}

Perfect Package section

Now you can input the page paths as a line feed (no need to separate the paths using commas or any characters)

Perfect Package Result

Provide the desired package name and description and you can see the package path will be prompted and you will be able to download the built package by clicking on the link:

Perfect Package result
Perfect package built and has all the references

Working Code:
You can also access the working code on my GitHub repository link: https://github.com/kiransg89/aemoperations

Note: You can access this package over domain on AEM / AEMaaCS and over any environments and architects can handle the permissions by adding appropriate rep:policy on cq generator node or conf page

AEM Tool – Create a package using a list of paths

Problem statement:

How to create a package using a list of paths?

Avoid adding individual paths manually in the CRX Package manager

Option to package current page or pull children

Introduction:

AEM OOTB comes with multiple tools in AEM and to access all the tools you need to navigate to the tool section and select the appropriate sections to perform all the operations

For example:

  1. AEM operations
  2. Managing templates
  3. Cloud configurations
  4. ACS Commons tools etc..

Tools are an essential part of AEM and avoid any dependency on Groovy scripts or any add scripts and can be managed or extended at any given point in time.

Package Creator is an extension of the ACS Commons Package handler, but it’s UI friendly and easy to access. You can input the paths to be packaged as a line feed and no need to separate them by a comma. Once the package is ready it will be built and prompted with a download option.

In order to create a package please use the Tool generator to give your tool name and descriptions: https://kiransg.com/2022/11/24/aem-tool-create-generate-tool-from-scratch

Add the following Sling model fields as shown below to accept the inputs:

package com.aem.operations.core.models;

import javax.inject.Named;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.models.annotations.DefaultInjectionStrategy;
import org.apache.sling.models.annotations.Model;
import com.adobe.acs.commons.mcp.form.FormField;
import com.adobe.acs.commons.mcp.form.GeneratedDialog;
import com.adobe.acs.commons.mcp.form.SelectComponent;
import lombok.Getter;
import lombok.Setter;

@Model(adaptables = { Resource.class,SlingHttpServletRequest.class }, defaultInjectionStrategy = DefaultInjectionStrategy.OPTIONAL)
public class PackageCreatorModel extends GeneratedDialog {

    public enum GetChildren {
        NO, YES
    }

    @Getter
    @Setter
    @Named(value = "packageName")
    @FormField(
            name = "Package Name",
            category = "General",
            required = true,
            description = "Enter the package name",
            hint = "Name")
    private String packageName;

    @Getter
    @Setter
    @Named(value = "packageDescription")
    @FormField(
            name = "Package Description",
            category = "General",
            required = true,
            description = "Enter the package Description",
            hint = "Description")
    private String packageDescription;

    @Getter
    @Setter
    @Named(value = "getChildren")
    @FormField(
            name = "Get Children",
            description = "child pages will be pulled",
            required = true,
            component = SelectComponent.EnumerationSelector.class,
            options = {"horizontal", "default=NO"})
    GetChildren getChildren = GetChildren.NO;
}

Add the following Servlet to process the request as shown below:

package com.aem.operations.core.servlets;

import com.adobe.acs.commons.packaging.PackageHelper;
import com.adobe.granite.rest.Constants;
import com.aem.operations.core.models.PackageCreatorModel;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import org.apache.commons.lang3.StringUtils;
import org.apache.jackrabbit.vault.fs.io.AccessControlHandling;
import org.apache.jackrabbit.vault.packaging.*;
import org.apache.sling.api.SlingHttpServletRequest;
import org.apache.sling.api.SlingHttpServletResponse;
import org.apache.sling.api.resource.Resource;
import org.apache.sling.api.resource.ResourceResolver;
import org.apache.sling.api.servlets.SlingAllMethodsServlet;
import org.apache.sling.servlets.post.JSONResponse;
import org.jetbrains.annotations.Nullable;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.servlet.Servlet;
import javax.servlet.ServletException;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.*;
import java.util.stream.Collectors;

@Component(
        service = { Servlet.class },
        property = {
                "sling.servlet.paths=" + PackageCreatorServlet.RESOURCE_PATH,
                "sling.servlet.methods=POST"
        }
)
public class PackageCreatorServlet extends SlingAllMethodsServlet {

    private static final long serialVersionUID = 1L;
    private static final Logger LOGGER = LoggerFactory.getLogger(PackageCreatorServlet.class);
    public static final String RESOURCE_PATH = "/bin/triggerPackageCreator";
    private static final String MESSAGE = "message";
    private static final String CONTENT_DAM_SLASH = "/content/dam/";

    private static final String DEFUALT_GROUP_NAME = "my_packages";
    private static final String DEFUALT_VERSION = "1.0";
    private static final String QUERY_PACKAGE_THUMBNAIL_RESOURCE_PATH = "/apps/acs-commons/components/utilities/packager/query-packager/definition/package-thumbnail.png";

    @Reference
    private PackageHelper packageHelper;

    @Reference
    private Packaging packaging;

    @Override
    protected void doPost(SlingHttpServletRequest request, SlingHttpServletResponse response) throws ServletException, IOException {
        // Set response headers.
        response.setContentType(JSONResponse.RESPONSE_CONTENT_TYPE);
        response.setCharacterEncoding(Constants.DEFAULT_CHARSET);

        JsonObject jsonResponse = new JsonObject();
        ResourceResolver resourceResolver = request.getResourceResolver();

        String packageName = request.getParameter("packageName");
        String packageDescription = request.getParameter("packageGroup");
        String pathsList = request.getParameter("pathsList");
        String getChildren = request.getParameter("getChildren");

        String packagePath = StringUtils.EMPTY;
        if(StringUtils.isNotEmpty(pathsList)) {
            packagePath = createPackage(resourceResolver, packageName, packageDescription, pathsList, getChildren);
        }

        if(StringUtils.isNotEmpty(packagePath)) {
            jsonResponse.addProperty(MESSAGE, "Package creation is Successful and path is <a href=\"" + packagePath + "\">" + packagePath + "</a>");
        } else {
            LOGGER.error("Unable to process package creation");
        }
        try (PrintWriter out = response.getWriter()) {
            out.print(new Gson().toJson(jsonResponse));
        }
    }

    private String createPackage(ResourceResolver resourceResolver, String packageName, String packageDescription,
                                 String pathsList, String getChildren) throws IOException {
        Set<String> pages = new HashSet<>(Arrays.asList(StringUtils.split(pathsList, '\n'))).stream().map(String::trim).collect(Collectors.toSet());
        Set<Resource> packageResources = pages.stream().map(path -> getResource(resourceResolver, path, getChildren)).collect(Collectors.toSet());
        if(null != packageResources && !packageResources.isEmpty()) {
            Map<String, String> packageDefinitionProperties = new HashMap<>();
            // ACL Handling
            packageDefinitionProperties.put(JcrPackageDefinition.PN_AC_HANDLING,
                    AccessControlHandling.OVERWRITE.toString());
            // Package Description
            packageDefinitionProperties.put(JcrPackageDefinition.PN_DESCRIPTION, packageDescription);
            try(JcrPackage jcrPackage = packageHelper.createPackage(packageResources,
                    resourceResolver.adaptTo(Session.class), DEFUALT_GROUP_NAME, packageName, DEFUALT_VERSION,
                    PackageHelper.ConflictResolution.Replace, packageDefinitionProperties)){
                // Add thumbnail to the package definition
                packageHelper.addThumbnail(jcrPackage, resourceResolver.getResource(QUERY_PACKAGE_THUMBNAIL_RESOURCE_PATH));
                final JcrPackageManager packageManager = packaging.getPackageManager(resourceResolver.adaptTo(Session.class));
                packageManager.assemble(jcrPackage, null);
                return jcrPackage.getNode().getPath();
            } catch (RepositoryException | PackageException e) {
                LOGGER.error("Exception occurred during package creation {}", e.getMessage());
            }
        }
        return StringUtils.EMPTY;
    }

    private @Nullable Resource getResource(ResourceResolver resourceResolver, String path, String getChildren) {
        if(StringUtils.equalsIgnoreCase(PackageCreatorModel.GetChildren.NO.toString(), getChildren) && !StringUtils.contains(path, CONTENT_DAM_SLASH)) {
            return resourceResolver.resolve(path + "/jcr:content");
        }
        return resourceResolver.resolve(path);
    }
}

Add the following HTL to render the page:

<!DOCTYPE html>
<head>
   <link rel="shortcut icon" href="/libs/granite/core/content/login/favicon.ico">
</head>
<body class="coral--light foundation-layout-util-maximized-alt">
   <sly data-sly-call="${clientLib.all @ categories=['coralui3','granite.ui.coral.foundation','granite.ui.shell','cq.authoring.dialog', 'packagecreator.base']}"
      data-sly-use.clientLib="/libs/granite/sightly/templates/clientlib.html"/>
   <coral-shell-header aria-hidden="false" aria-label="Header Bar"
      class="coral--dark granite-shell-header coral3-Shell-header"
      role="region">
      <coral-shell-header-home aria-level="2" class="globalnav-toggle" data-globalnav-toggle-href="/" role="heading">
         <a class="coral3-Shell-homeAnchor" href="/"
            icon="adobeExperienceManagerColor" is="coral-shell-homeanchor"
            style="display: inline-block; padding-right: 0;">
            <coral-icon
               aria-label="adobe experience manager color"
               class="coral3-Icon coral3-Shell-homeAnchor-icon coral3-Icon--adobeExperienceManagerColor coral3-Icon--sizeM"
               icon="adobeExperienceManagerColor" role="img"
               size="M"></coral-icon>
            <coral-shell-homeanchor-label>Adobe Experience Manager
            </coral-shell-homeanchor-label>
         </a>
         <span style="line-height: 2.375rem;">/ AEM Operations / ${properties.jcr:title} </span>
      </coral-shell-header-home>
   </coral-shell-header>
   <div class="foundation-layout-panel">
      <div class="foundation-layout-panel-header">
         <betty-titlebar>
            <betty-titlebar-title>
               <span aria-level="1" class="granite-title" role="heading">${properties.jcr:title}</span>
            </betty-titlebar-title>
            <betty-titlebar-primary></betty-titlebar-primary>
            <betty-titlebar-secondary>
            </betty-titlebar-secondary>
         </betty-titlebar>
      </div>
      <div class="content-container-inner coral-Well" style="width:60%; margin:0 auto;">
         <div class="acsCommons-System-Notifications-Form-row">
		   <label class="coral-Form-fieldlabel">List of Paths</label>
		   <label> Note: Add the list of paths has a Line feed (No need for comma separation)</label>
		   <textarea
		      class="coral-Textfield coral-Textfield--multiline acsCommons-System-Notifications-Page-input--textarea"
		      rows="6"
		      name="./pathsList"></textarea>
		 </div>
         <sly data-sly-use.packprops="com.aem.operations.core.models.PackageCreatorModel">
            <sly data-sly-resource="${packprops.formResource}"/>
         </sly>
         <coral-actionbar-item class="coral3-ActionBar-item">
            <button class="coral3-Button coral3-Button--primary package-initiator" icon="checkCircle" iconsize="S"
               is="coral-button"
               size="M" variant="primary">
               <coral-icon alt="" class="coral3-Icon coral3-Icon--sizeS coral3-Icon--checkCircle" icon="actions"
                  size="S"></coral-icon>
               <coral-button-label>
                  Create Package
               </coral-button-label>
            </button>
         </coral-actionbar-item>
          <!-- The Modal -->
          <div id="popup-modal" class="modal">
              <!-- Modal content -->
              <div class="modal-content">
                  <span class="close">&times;</span>
                  <span class="loading">Loading</span>
                  <span class="loader"></span>
              </div>
          </div>
      </div>
   </div>
</body>

Add the following JS code to process the ajax request as shown below:

var Coral = window.Coral || {},
    Granite = window.Granite || {};

(function (window, document, $, Coral) {
    "use strict";
    $(document).on("foundation-contentloaded", function (e) {

        var SITE_PATH = "/conf/aemoperations/settings/tools/create-package-initiator.html",
            ui = $(window).adaptTo("foundation-ui");

        if (window.location.href.indexOf(SITE_PATH) < 0) {
            return;
        }

        $(document).off("change", ".close").on("change", ".close", function (event) {
            $(".modal").hide();
        });

        function getValueByName(fieldName, isMandatory) {
            var fieldValue = ($("input[name='" + fieldName + "']").val()).trim();
            if (!isMandatory) {
                return fieldValue;
            }
            if (!fieldValue || fieldValue.length === 0) {
                //for input fields
                $("input[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("input[name='" + fieldName + "']").attr('invalid', 'invalid');

                //for select fields
                $("coral-select[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("coral-select[name='" + fieldName + "']").attr('invalid', 'invalid');

                return;
            } else {
                return fieldValue;
            }
        }

        function getValueByNameArea(fieldName, isMandatory) {
            var fieldValue = ($("textarea[name='" + fieldName + "']").val()).trim();
            if (!isMandatory) {
                return fieldValue;
            }
            if (!fieldValue || fieldValue.length === 0) {
                //for input fields
                $("textarea[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("textarea[name='" + fieldName + "']").attr('invalid', 'invalid');

                //for select fields
                $("coral-select[name='" + fieldName + "']").attr('aria-invalid', 'true');
                $("coral-select[name='" + fieldName + "']").attr('invalid', 'invalid');

                return;
            } else {
                return fieldValue;
            }
        }

        $(document).off("click", ".package-initiator").on("click", ".package-initiator", function (event) {
            event.preventDefault();

            var packageName = getValueByName('./packageName', true),
                packageDescription = getValueByName('./packageDescription', true),
                pathsList = getValueByNameArea('./pathsList', true),
                getChildren = getValueByName('./getChildren', true);

            var formData = new FormData();
            formData.append("packageName", packageName);
            formData.append("packageDescription", packageDescription);
            formData.append("pathsList", pathsList);
            formData.append("getChildren", getChildren);

			$(".loading").html("PLEASE WAIT CREATINGING PACKAGE");
            $(".modal").show();

            $.ajax({
                url: "/bin/triggerPackageCreator",
                method: "POST",
                async: true,
                cache: false,
                contentType: false,
                processData: false,
                data: formData
            }).done(function (data) {
                if (data && data.message){
                    ui.notify("Success", data.message, "success");
                    var dialog = new Coral.Dialog();
                    dialog.id = 'dialogSuccess';
                    dialog.header.innerHTML = 'Success';
                    dialog.content.innerHTML = data.message;
                    dialog.footer.innerHTML = '<button class="ok-button" is="coral-button" variant="primary" icon="check" coral-close>OK</button>';
                    dialog.variant = 'success';
                    dialog.closable = "on";
                    dialog.show();
                    $(".modal").hide();
                }else{
                    ui.notify("Error", "Unable to create package ", "error");
                }
            }).fail(function (data) {
				$(".modal").hide();
                if (data && data.responseJSON && data.responseJSON.message){
                    ui.notify("Error", data.responseJSON.message, "error");
                }else{
                    ui.notify("Error", "Unable to create package", "error");
                }
            });
        });
    });
})(window, document, $, Coral);

Once the code is built and deployed you will be able to access the tool by navigating the:

Tools -> {Tool Section} -> {Tool Name}

Package Creator

Now you can input the page paths as a line feed (no need to separate the paths using commas or any characters)

Creator Page

Provide the list paths to be backed up, desired package name, and description and select the dropdown to pull children or not. Once the package is built you can see the package path will be prompted and you will be able to download the package by clicking on the built package link:

Package Creator Results
The package is built with the exact page location

Working Code:
You can also access the working code on my GitHub repository link: https://github.com/kiransg89/aemoperations

Note: You can access this package over domain on AEM / AEMaaCS and over any environments and architects can handle the permissions by adding appropriate rep:policy on cq generator node or conf page

AEM Tool – Create / Generate tool from scratch

Problem Statement:

Create a tool similar to AEM Operations or ACS Commons for easy access and to run any maintenance process/tasks.

Introduction:

AEM OOTB comes with multiple tools in AEM and to access all the tools you need to navigate to the tool section and select the appropriate sections to perform all the operations

For example:

  1. AEM operations
  2. Managing templates
  3. Cloud configurations
  4. ACS Commons tools etc..

Tools are an essential part of AEM and avoid any dependency on Groovy scripts or any add scripts and can be managed or extended at any given point in time.

In order to create any tool from scratch takes a lot of time and man hours and once all the configs are ready then it takes more time to develop the services and servlet to handle the business logic.

By using this tool, you can avoid all kinds of configurations and initial setup and kick start with your own first tool from scratch

The tool generator consists of the:

  1. Sling model – to generate fields and handle inputs
  2. Servlet – to process request
  3. Component – to handle view (HTL), CSS, and JS

All the above sample scratch setups along with ready to check-in code will be added to the code base.

How to use the tool?

1. Click on the link to download the tool generator package and install it into your local instance
2. Once the package is installed go to sites -> tools -> and select the Tool Generator section
tool section
3. Select Tool Settings on the top right hand corner and provide your local repository paths like sling model path, servlet path, apps path, conf path, and CQ path if it already exists
tool page
4. Once all the settings are authored save the settings
tool settings
5. After coming to the generator page provide your tool name and tool description
tool authoring
6. If you have an existing tool then select Yes else Select No and provide the Tool Section name
7. Click on Create tool to create your new tool from scratch

Check your repository for all the file changes as shown below:

You can also check your new tool component and other configs on CRXDE

You can also visit your new tool by accessing the tool section (sites -> Tools -> {Your section name})

For more info on how to add a Sling model field please visit the below link:

Once your Sling model, servlet, and other things are ready make sure you add the following filter in your META-INF folder and keep it merge as shown below:

This would avoid replacing/overriding any other tools like ACS Commons or your other repo tools.