Full text search AEM 6.5

Perform a full text search in AEM 6.5

Goal

Create a component that performs a full text search. The example will show only the first step of a search, that is the search bar. We won’t cover the “view all results” case/the result page and its pagination, but there will be some hints inside the code to achieve it. This is what we are going to develop:

Procedure

Let’s create our component as follows:

  1. The Dialog XML File:
<?xml version="1.0" encoding="UTF-8"?>
<jcr:root xmlns:sling="http://sling.apache.org/jcr/sling/1.0" xmlns:cq="http://www.day.com/jcr/cq/1.0" xmlns:jcr="http://www.jcp.org/jcr/1.0" xmlns:nt="http://www.jcp.org/jcr/nt/1.0"
    jcr:primaryType="nt:unstructured"
    jcr:title="Search"
    sling:resourceType="cq/gui/components/authoring/dialog">
    <content
        jcr:primaryType="nt:unstructured"
        sling:resourceType="granite/ui/components/coral/foundation/container">
        <items jcr:primaryType="nt:unstructured">
            <tabs
                jcr:primaryType="nt:unstructured"
                sling:resourceType="granite/ui/components/coral/foundation/tabs"
                maximized="{Boolean}true">
                <items jcr:primaryType="nt:unstructured">
                    <properties
                        jcr:primaryType="nt:unstructured"
                        jcr:title="Properties"
                        sling:resourceType="granite/ui/components/coral/foundation/container"
                        margin="{Boolean}true">
                        <items jcr:primaryType="nt:unstructured">
                            <columns
                                jcr:primaryType="nt:unstructured"
                                sling:resourceType="granite/ui/components/coral/foundation/fixedcolumns"
                                margin="{Boolean}true">
                                <items jcr:primaryType="nt:unstructured">
                                    <column
                                        jcr:primaryType="nt:unstructured"
                                        sling:resourceType="granite/ui/components/coral/foundation/container">
                                        <items jcr:primaryType="nt:unstructured">
                                            <searchRoot
                                                    jcr:primaryType="nt:unstructured"
                                                    sling:resourceType="granite/ui/components/coral/foundation/form/pathbrowser"
                                                    rootPath="/content/"
                                                    fieldLabel="Search Root Link"
                                                    name="./searchRoot"/>
                                            <minChar
                                                    jcr:primaryType="nt:unstructured"
                                                    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                    fieldLabel="Min char to start search"
                                                    name="./minChar"/>
                                            <previewLimit
                                                jcr:primaryType="nt:unstructured"
                                                sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                fieldDescription="Enter the title text for this search component"
                                                fieldLabel="Preview Limit in search bar"
                                                name="./previewLimit"/>
                                            <resultSize
                                                    jcr:primaryType="nt:unstructured"
                                                    sling:resourceType="granite/ui/components/coral/foundation/form/textfield"
                                                    fieldLabel="Result size (for query)"
                                                    name="./resultSize"/>
                                            <searchResultUrl
                                                    jcr:primaryType="nt:unstructured"
                                                    sling:resourceType="granite/ui/components/coral/foundation/form/pathbrowser"
                                                    rootPath="/content/"
                                                    fieldLabel="Result Page Link"
                                                    name="./searchResultUrl"/>
                                        </items>
                                    </column>
                                </items>
                            </columns>
                        </items>
                    </properties>
                </items>
            </tabs>
        </items>
    </content>
</jcr:root>

And configure:

  • Search Root: path used to hit the servlet and used by the query builder to search the results. (I used “/content/we-retail/us/en”, if you have the We.Retail project, you can try it as well)
  • Min Char: minimum number of user digits to start the search (e.g. 3)
  • Preview Limit: number of items shown under the search bar (e.g. 5)
  • Result Size (not used – hint): Items per page if you configure a result page with all the result
  • Search Result (not used – hint): URL of the result page

2. The HTML file:

<div class="container">
    <template id="bc-search-results--hint__template">
        <div class='bc-searchbar-preview--line' data-url='{{url}}' tabindex="0">{{{title}}}</div>
    </template>
    <div class="s01" id="searchbar" data-search-url="${properties.searchRoot}.search.json" data-search-min-char="${properties.minChar}"
         data-search-items-per-page="${properties.resultSize}" data-search-preview-limit="${properties.previewLimit}" data-search-delay-ms="500"
         data-view-all-search-results-url="${properties.searchResultUrl}">
        <div class="flex">
            <div class="input-field first-wrap" aria-label="Search">
                <input placeholder="Search" id="search" aria-label="To search the site, type a search term" />
            </div>
            <div class="input-field third-wrap">
                <button class="btn-search" type="button">Search</button>
            </div>
        </div>
        <!-- Search results preview -->
        <div class="bc-searchbar-preview d-none" tabindex="0">
            <div class="bc-searchbar-viewall">
                <span>VIEW ALL SEARCH RESULTS</span><!--/* NOT USED*/-->
            </div>
        </div>
    </div>
    <!-- Search bar -->

    <sly data-sly-use.clientlib="/libs/granite/sightly/templates/clientlib.html">
        <sly data-sly-call="${clientlib.all @ categories='clientlib.Lab2020-search'}"/>
    </sly>
</div>

The front end code is really basic. It’s just to show you a preview of the component. You will adapt it based on your project and your requirements. Indeed I won’t share the CSS. As you have seen, I used Handlebars to render the list of results.

3. Create your clientlibs and in particular the JS file (as I said, you will need Handlebars and jQuery as well to have your component working):

(function (window, document, $) {

    function undefToBlank(val){
        return typeof val !== "undefined"?val:"";
    }

    function simpleLocationToHash() {
      var location = {};
      window.location.search.replace(/\?/i, '').split('&').map(function (o) {
        var sp = o.split('=');
        location[sp[0]] = sp[1];
      });
      return location;
    }

    'use strict'; // Require dependencies here

    var $searchBar = $("#searchbar");
    var perPage = $searchBar.attr('data-search-items-per-page');
    var resultsPageUrl = $searchBar.attr("data-view-all-search-results-url");
    var searchUrl = $searchBar.attr("data-search-url");
    var previewLimit = $searchBar.attr("data-search-preview-limit");
    var minChar = $searchBar.attr("data-search-min-char");
    var searchDelay = $searchBar.attr("data-search-delay-ms");
    var searchVal = $searchBar.find('input').val();
     var kw = '';
    var query = simpleLocationToHash();
    var offset = typeof query.resultsOffset != 'undefined' ? parseInt(query.resultsOffset) : 0; //var $searchBar = $('.bc-searchbar');

    var $searchPreview = $searchBar.find('.bc-searchbar-preview');
    var $searchInput = $searchBar.find("input");
    var queryText = decodeURI(query.fulltext);
    var KEY_ENTER = 13,
        KEY_ESC = 27;

    // not used - to go to the result page
    var $viewAllSearchResults = $searchBar.find('.bc-searchbar-viewall');

    var hintTemplate = Handlebars.compile(document.getElementById("bc-search-results--hint__template").innerHTML);

    function populateSearchResultsPreview(searchResults, kw) {
        if (!Array.isArray(searchResults.results) || searchResults.results.length <= 0) return $searchPreview.hide();
        var $searchPreviewLine = $searchBar.find('.bc-searchbar-preview--line');
        $searchPreviewLine.remove();
        $searchPreview.prepend(searchResults.results.map(function (o, i) {
        if (i < previewLimit) {
          o.title = "<span class=''>" + o.title + "</span>";
          return hintTemplate(o);
        }
      }));
      $searchPreviewLine = $searchBar.find('.bc-searchbar-preview--line'); // navigate to a certain page upon clicking the search results

      $searchPreviewLine.on("click", function () {
        window.location = $(this).attr("data-url");
      });
      $searchPreview.show();
    } // we wait for 100ms before we issue a search, so that the user gets time to finish typing

    var delayedSearch;
    $searchInput.on("keyup", function (e) {
      if (delayedSearch) {
        clearTimeout(delayedSearch);
        delayedSearch = null;
      }

      kw = $(this).val();
      if (kw.length < minChar) return populateSearchResultsPreview([]);
      var url = searchUrl + createUrl(0, undefined, kw);
      delayedSearch = setTimeout(function () {
        var response = $.ajax({
           url: url,
           dataType: 'JSON',
           type: 'GET',
           headers: {
               "Content-Type": "application/json"
           }
        });
        response.done(function(data){
            populateSearchResultsPreview(data, kw)
            });
      }, searchDelay);
    }).on("keydown", function (e) {
      if (e.which === KEY_ENTER) {
        gotoResults(); //could be used
      }
    });
    /* view all results */

    // initialize the module here
    var urlTemplate = Handlebars.compile("?fulltext={{fulltext}}&resultsOffset={{resultsOffset}}");

    function createUrl(page, resultsPage, url) {
      var ret = typeof resultsPage === 'undefined' ? urlTemplate({
        fulltext: typeof url === 'undefined' ? undefToBlank(query.fulltext) : url,
        resultsOffset: page * perPage
      }) : urlMaker.mergeParams({
        fulltext: typeof url === 'undefined' ? undefToBlank(query.fulltext) : url,
        resultsOffset: page * perPage
      }).serialize(true);
      return ret;
    }

//not used
function gotoResults() {
      var searchVal = $searchBar.find('input').val();
            window.location = resultsPageUrl + urlTemplate({
        fulltext: encodeURI(searchVal),
        resultsOffset: 0
      }); 
    }
})(window, document, jQuery);

4. Create the Servlet:

package com.adobe.training.core.servlets;

import com.day.cq.search.PredicateConverter;
import com.day.cq.search.PredicateGroup;
import com.day.cq.search.Query;
import com.day.cq.search.QueryBuilder;
import com.day.cq.search.result.Hit;
import com.day.cq.search.result.SearchResult;
import com.day.cq.wcm.api.NameConstants;
import com.day.cq.wcm.api.Page;
import com.day.cq.wcm.api.PageManager;
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.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;
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 java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * @author riccardo teruzzi
 */
@Component(
        service = Servlet.class,
        property = {
                "sling.servlet.selectors=" + SearchResultServlet.DEFAULT_SELECTOR,
                "sling.servlet.resourceTypes=cq:Page",
                "sling.servlet.extensions=json",
                "sling.servlet.methods=GET"
        })
public class SearchResultServlet extends SlingAllMethodsServlet {

    public static final String PREDICATE_FULLTEXT = "fulltext";
    public static final String PREDICATE_TYPE = "type";
    public static final String PREDICATE_PATH = "path";

    public static final String UTF8 = "UTF-8";
    public static final String APPLICATION_JSON = "application/json";
    protected static final String DEFAULT_SELECTOR = "search";
    protected static final String PARAM_FULLTEXT = "fulltext";
    private static final String PARAM_RESULTS_OFFSET = "resultsOffset";
    private static final Logger LOGGER = LoggerFactory.getLogger(SearchResultServlet.class);

    @Reference
    private QueryBuilder queryBuilder;

    @Override
    protected void doGet(SlingHttpServletRequest request, SlingHttpServletResponse response)
            throws IOException {
        try {
            JSONObject jsonObject = getResults(request);
            response.setContentType(APPLICATION_JSON);
            response.setCharacterEncoding(UTF8);
            response.getWriter().print(jsonObject);
        } catch (IOException | JSONException e) {
            LOGGER.error(e.getMessage());
        }
    }


    private JSONObject getResults(SlingHttpServletRequest request) throws JSONException {
        int searchTermMinimumLength = 3;
        int resultsSize = 10; //For pagination
        String searchRoot = request.getResource().getPath();
        JSONObject response = new JSONObject();
        JSONArray results = new JSONArray();
        String fulltext = request.getParameter(PARAM_FULLTEXT);
        if (fulltext == null || fulltext.length() < searchTermMinimumLength) {
            return response;
        }
        long resultsOffset = 0; //For pagination
        if (request.getParameter(PARAM_RESULTS_OFFSET) != null) {
            resultsOffset = Long.parseLong(request.getParameter(PARAM_RESULTS_OFFSET));
        }
        Map<String, String> predicatesMap = new HashMap<>();
        predicatesMap.put(PREDICATE_FULLTEXT, fulltext);
        predicatesMap.put(PREDICATE_PATH, searchRoot);
        predicatesMap.put(PREDICATE_TYPE, NameConstants.NT_PAGE);

        PredicateGroup predicates = PredicateConverter.createPredicates(predicatesMap);
        ResourceResolver resourceResolver = request.getResource().getResourceResolver();
        Query query = queryBuilder.createQuery(predicates, resourceResolver.adaptTo(Session.class));
        query.setHitsPerPage(resultsSize);
        if (resultsOffset != 0) {
            query.setStart(resultsOffset);
        }
        SearchResult searchResult = query.getResult();
        long totalMatches = searchResult.getTotalMatches();

        PageManager pageManager = resourceResolver.adaptTo(PageManager.class);
        List<Hit> hits = searchResult.getHits();
        if (hits != null && pageManager != null) {
            for (Hit hit : hits) {
                try {
                    JSONObject result = new JSONObject();
                    Resource hitRes = hit.getResource();
                    Page page = pageManager.getContainingPage(hitRes);
                    if (page != null) {
                        result.put("title", page.getTitle());
                        result.put("url", resourceResolver.map(page.getPath()) + ".html");
                        results.put(result);
                    }
                } catch (RepositoryException e) {
                    LOGGER.error("Unable to retrieve search results for query.", e);
                }
            }
        }
        response.put("totalMatches", totalMatches);
        response.put("results", results);
        return response;
    }

}

Everything should be clear, I hope. The resultSize and the offSet could be used for the second step of your search (the result page and the pagination).

As I said, you will probably adapt a lot of the HTML/JS based on your project, but I think this could be a good start if you need to implement a full text search .

Cheers! 🍻

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: