ilscipio/scipio-erp

View on GitHub
applications/product/src/org/ofbiz/product/category/CatalogUrlFilter.java

Summary

Maintainability
F
4 days
Test Coverage
/*******************************************************************************
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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 org.ofbiz.product.category;

import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;

import javax.servlet.FilterChain;
import javax.servlet.RequestDispatcher;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.ofbiz.base.util.Debug;
import org.ofbiz.base.util.StringUtil;
import org.ofbiz.base.util.UtilValidate;
import org.ofbiz.common.UrlServletHelper;
import org.ofbiz.entity.Delegator;
import org.ofbiz.entity.GenericEntityException;
import org.ofbiz.entity.GenericValue;
import org.ofbiz.entity.util.EntityQuery;
import org.ofbiz.entity.util.EntityUtil;
import org.ofbiz.product.catalog.CatalogWorker;
import org.ofbiz.product.category.CatalogUrlServlet.CatalogUrlBuilder;
import org.ofbiz.product.product.ProductContentWrapper;
import org.ofbiz.service.LocalDispatcher;
import org.ofbiz.webapp.FullWebappInfo;
import org.ofbiz.webapp.control.ContextFilter;
import org.ofbiz.webapp.control.RequestHandler;
import org.ofbiz.webapp.control.RequestLinkUtil;

/**
 * Catalog URL filter - processes requests generated by <code>@ofbizContentAltUrl</code>.
 * <p>
 * SCIPIO: FIXME?: There is a limitation in this filter that the trail will only be set to the product or category's
 * default category from the top catalog category. This defeat's Ofbiz's schema which allows a product or category
 * to have multiple parents. As a result, other similar calls have been restricted. See {@link #makeTrailElements}.
 */
public class CatalogUrlFilter extends ContextFilter {

    private static final Debug.OfbizLogger module = Debug.getOfbizLogger(java.lang.invoke.MethodHandles.lookup().lookupClass());

    /**
     * @deprecated SCIPIO: 2017: this was unhardcoded; use {@link org.ofbiz.webapp.control.RequestHandler#getControlServletPath(ServletRequest)}.
     */
    @Deprecated
    public static final String CONTROL_MOUNT_POINT = "control";
    public static final String PRODUCT_REQUEST = "product";
    public static final String CATEGORY_REQUEST = "category";

    protected static String defaultLocaleString = null;
    protected static String redirectUrl = null;

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        Delegator delegator = (Delegator) httpRequest.getServletContext().getAttribute("delegator"); // SCIPIO: NOTE: no longer need getSession() for getServletContext(), since servlet API 3.0

        // SCIPIO: 2017-11-08: factored out
        prepareRequestAlways(httpRequest, httpResponse, delegator);

        // set initial parameters
        String initDefaultLocalesString = config.getInitParameter("defaultLocaleString");
        String initRedirectUrl = config.getInitParameter("redirectUrl");
        setDefaultLocaleString(UtilValidate.isNotEmpty(initDefaultLocalesString) ? initDefaultLocalesString : "");
        setRedirectUrl(UtilValidate.isNotEmpty(initRedirectUrl) ? initRedirectUrl : "");

        String pathInfo = httpRequest.getServletPath();
        if (UtilValidate.isNotEmpty(pathInfo)) {
            List<String> pathElements = StringUtil.split(pathInfo, "/");
            String alternativeUrl = pathElements.get(0);

            String productId = null;
            String productCategoryId = null;
            String urlContentId = null;
            try {
                if (alternativeUrl.endsWith("-p")) { // look for productId
                    productId = extractAltUrlProductId(delegator, alternativeUrl, "-p");
                } else if (alternativeUrl.endsWith("-c")) { // look for productCategoryId
                    productCategoryId = extractAltUrlCategoryId(delegator, alternativeUrl, "-c");
                }
            } catch (GenericEntityException e) {
                Debug.logWarning("Cannot look for product and product category: " + e.getMessage(), module);
            }

            // SCIPIO: 2017-11-07: this ID check was previously much further below, but has been moved here to lower the needless overhead
            if (UtilValidate.isNotEmpty(productId) || UtilValidate.isNotEmpty(productCategoryId) || UtilValidate.isNotEmpty(urlContentId)) {

                // SCIPIO: FIXME?: The code below also is only equivalent to makeDefaultCategoryTrailElements,
                // which is it will only look up a default path under the top category, which is generally
                // not desirable but accepted for simple shops.

                // SCIPIO: get default category for product
                if (UtilValidate.isNotEmpty(productId) && UtilValidate.isEmpty(productCategoryId)) {
                    // SCIPIO: factored out
                    productCategoryId = getProductDefaultCategoryId(delegator, productId);
                }

                // SCIPIO: 2016-03-22: FIXME?: the getCatalogTopCategory call below
                // is currently left unchanged, but note that because of it,
                // currently CatalogUrlFilter/ofbizCatalogAltUrl force browsing toward only the main top catalog category.
                // It does not allow browsing any other top categories (best-selling, promotions, etc.).
                // In some cases this is desirable, in others not.

                // generate trail belong to a top category
                // SCIPIO: 2017-08-15: this call is inappropriate; see method for details
                //String topCategoryId = CategoryWorker.getCatalogTopCategory(httpRequest, null);
                String topCategoryId = getCatalogTopCategory(httpRequest);
                List<GenericValue> trailCategories = CategoryWorker.getRelatedCategoriesRet(httpRequest, "trailCategories", topCategoryId, false, false, true);
                List<String> trailCategoryIds = EntityUtil.getFieldListFromEntityList(trailCategories, "productCategoryId", true);

                // look for productCategoryId from productId
                if (UtilValidate.isNotEmpty(productId)) {
                    // SCIPIO: factored out
                    String catId = getProductMatchingCategoryId(delegator, productId, trailCategoryIds);
                    if (catId != null) {
                        productCategoryId = catId;
                    }
                }

                // SCIPIO: 2016-03-22: FIXME?: The loop below was found to cause invalid category paths in SOLR addToSolr
                // (was very similar code) and had to be fixed there. I think there is a chance there may be bugs here as well,
                // but I'm not certain.

                // generate trail elements from productCategoryId
                if (UtilValidate.isNotEmpty(productCategoryId)) {
                    // SCIPIO: 2017-11-07: factored out.
                    getTrailElementsAndUpdateRequestAndTrail(httpRequest, delegator, productId, productCategoryId, trailCategoryIds, topCategoryId);
                }

                // SCIPIO: bumped this lower to simplify all code
                // generate forward URL
                StringBuilder urlBuilder = new StringBuilder();
                urlBuilder.append(getControlServletPath(httpRequest));

                if (UtilValidate.isNotEmpty(productId)) {
                    urlBuilder.append("/" + PRODUCT_REQUEST);
                } else {
                    urlBuilder.append("/" + CATEGORY_REQUEST);
                }

                //Set view query parameters
                UrlServletHelper.setViewQueryParameters(request, urlBuilder);
                // SCIPIO: 2017-11-07: moved this check earlier to avoid large overhead on non-catalog calls
                //if (UtilValidate.isNotEmpty(productId) || UtilValidate.isNotEmpty(productCategoryId) || UtilValidate.isNotEmpty(urlContentId)) {
                Debug.logInfo("[Filtered request]: " + pathInfo + " (" + urlBuilder + ")", module);
                ContextFilter.setAttributesFromRequestBody(request);
                RequestDispatcher dispatch = request.getRequestDispatcher(urlBuilder.toString());
                dispatch.forward(request, response);
                return;
                //}

            } else {
                // SCIPIO: TODO/FIXME?: REVIEW: these calls were previously running even on non-alt-url requests;
                // as result, we can't remove them without testing to make sure no negative impacts
                // on rest of store... but highly dubious if they belong here...
                getCatalogTopCategory(httpRequest);
                UrlServletHelper.setViewQueryParameters(request, new StringBuilder());
            }

            //Check path alias
            UrlServletHelper.checkPathAlias(request, httpResponse, delegator, pathInfo);
        }

        // we're done checking; continue on
        chain.doFilter(request, response);
    }

    /**
     * SCIPIO: Actions that must run at every single request, whether handled or not.
     * Factored out from doFilter.
     */
    public static void prepareRequestAlways(HttpServletRequest request, HttpServletResponse response, Delegator delegator) throws UnsupportedEncodingException {
        ContextFilter.setCharacterEncoding(request);

        //Set request attribute and session
        UrlServletHelper.setRequestAttributes(request, delegator, request.getServletContext());
    }

    /**
     * SCIPIO: Returns control servlet path or empty string (same as getServletPath).
     */
    protected String getControlServletPath(ServletRequest request) {
        return RequestHandler.getControlServletPath(request);
        //return "/" + CONTROL_MOUNT_POINT;
    }

    /**
     * SCIPIO: Tries to match an alt URL path element to a product.
     * Factored out code from doFilter.
     * Added 2017-11-07.
     */
    public static String extractAltUrlProductId(Delegator delegator, String alternativeUrl, String suffix) throws GenericEntityException {
        String productId = null;
        List<GenericValue> productContentInfos = EntityQuery.use(delegator).from("ProductContentAndInfo")
                .where("productContentTypeId", "ALTERNATIVE_URL").orderBy("-fromDate").filterByDate().cache(true).queryList();
        if (UtilValidate.isNotEmpty(productContentInfos)) {
            for (GenericValue productContentInfo : productContentInfos) {
                String contentId = (String) productContentInfo.get("contentId");
                List<GenericValue> ContentAssocDataResourceViewTos = EntityQuery.use(delegator).from("ContentAssocDataResourceViewTo").where("contentIdStart", contentId, "caContentAssocTypeId", "ALTERNATE_LOCALE", "drDataResourceTypeId", "ELECTRONIC_TEXT").cache(true).queryList();
                if (UtilValidate.isNotEmpty(ContentAssocDataResourceViewTos)) {
                    for (GenericValue ContentAssocDataResourceViewTo : ContentAssocDataResourceViewTos) {
                        GenericValue ElectronicText = ContentAssocDataResourceViewTo.getRelatedOne("ElectronicText", true);
                        if (UtilValidate.isNotEmpty(ElectronicText)) {
                            String textData = (String) ElectronicText.get("textData");
                            textData = UrlServletHelper.invalidCharacter(textData);
                            if (alternativeUrl.matches(textData + ".+$")) {
                                String productIdStr = null;
                                productIdStr = alternativeUrl.replace(textData + "-", "");
                                if (suffix != null) productIdStr = productIdStr.replace(suffix, ""); // SCIPIO
                                String checkProductId = (String) productContentInfo.get("productId");
                                if (productIdStr.equalsIgnoreCase(checkProductId)) {
                                    productId = checkProductId;
                                    break;
                                }
                            }
                        }
                    }
                }
                if (UtilValidate.isEmpty(productId)) {
                    List<GenericValue> contentDataResourceViews = EntityQuery.use(delegator).from("ContentDataResourceView").where("contentId", contentId, "drDataResourceTypeId", "ELECTRONIC_TEXT").cache(true).queryList();
                    for (GenericValue contentDataResourceView : contentDataResourceViews) {
                        GenericValue ElectronicText = contentDataResourceView.getRelatedOne("ElectronicText", true);
                        if (UtilValidate.isNotEmpty(ElectronicText)) {
                            String textData = (String) ElectronicText.get("textData");
                            if (UtilValidate.isNotEmpty(textData)) {
                                textData = UrlServletHelper.invalidCharacter(textData);
                                if (alternativeUrl.matches(textData + ".+$")) {
                                    String productIdStr = null;
                                    productIdStr = alternativeUrl.replace(textData + "-", "");
                                    if (suffix != null) productIdStr = productIdStr.replace(suffix, ""); // SCIPIO
                                    String checkProductId = (String) productContentInfo.get("productId");
                                    if (productIdStr.equalsIgnoreCase(checkProductId)) {
                                        productId = checkProductId;
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        return productId;
    }

    /**
     * SCIPIO: Tries to match an alt URL path element to a category.
     * Factored out code from doFilter.
     * Added 2017-11-07.
     */
    public static String extractAltUrlCategoryId(Delegator delegator, String alternativeUrl, String suffix) throws GenericEntityException {
        String productCategoryId = null;
        List<GenericValue> productCategoryContentInfos = EntityQuery.use(delegator).from("ProductCategoryContentAndInfo")
                .where("prodCatContentTypeId", "ALTERNATIVE_URL").orderBy("-fromDate").filterByDate().cache(true).queryList();
        if (UtilValidate.isNotEmpty(productCategoryContentInfos)) {
            for (GenericValue productCategoryContentInfo : productCategoryContentInfos) {
                String contentId = (String) productCategoryContentInfo.get("contentId");
                List<GenericValue> ContentAssocDataResourceViewTos = EntityQuery.use(delegator).from("ContentAssocDataResourceViewTo").where("contentIdStart", contentId, "caContentAssocTypeId", "ALTERNATE_LOCALE", "drDataResourceTypeId", "ELECTRONIC_TEXT").cache(true).queryList();
                if (UtilValidate.isNotEmpty(ContentAssocDataResourceViewTos)) {
                    for (GenericValue ContentAssocDataResourceViewTo : ContentAssocDataResourceViewTos) {
                        GenericValue ElectronicText = ContentAssocDataResourceViewTo.getRelatedOne("ElectronicText", true);
                        if (UtilValidate.isNotEmpty(ElectronicText)) {
                            String textData = (String) ElectronicText.get("textData");
                            if (UtilValidate.isNotEmpty(textData)) {
                                textData = UrlServletHelper.invalidCharacter(textData);
                                if (alternativeUrl.matches(textData + ".+$")) {
                                    String productCategoryStr = null;
                                    productCategoryStr = alternativeUrl.replace(textData + "-", "");
                                    if (suffix != null) productCategoryStr = productCategoryStr.replace(suffix, ""); // SCIPIO
                                    String checkProductCategoryId = (String) productCategoryContentInfo.get("productCategoryId");
                                    if (productCategoryStr.equalsIgnoreCase(checkProductCategoryId)) {
                                        productCategoryId = checkProductCategoryId;
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
                if (UtilValidate.isEmpty(productCategoryId)) {
                    List<GenericValue> contentDataResourceViews = EntityQuery.use(delegator).from("ContentDataResourceView").where("contentId", contentId, "drDataResourceTypeId", "ELECTRONIC_TEXT").cache(true).queryList();
                    for (GenericValue contentDataResourceView : contentDataResourceViews) {
                        GenericValue ElectronicText = contentDataResourceView.getRelatedOne("ElectronicText", true);
                        if (UtilValidate.isNotEmpty(ElectronicText)) {
                            String textData = (String) ElectronicText.get("textData");
                            if (UtilValidate.isNotEmpty(textData)) {
                                textData = UrlServletHelper.invalidCharacter(textData);
                                if (alternativeUrl.matches(textData + ".+$")) {
                                    String productCategoryStr = null;
                                    productCategoryStr = alternativeUrl.replace(textData + "-", "");
                                    if (suffix != null) productCategoryStr = productCategoryStr.replace(suffix, ""); // SCIPIO
                                    String checkProductCategoryId = (String) productCategoryContentInfo.get("productCategoryId");
                                    if (productCategoryStr.equalsIgnoreCase(checkProductCategoryId)) {
                                        productCategoryId = checkProductCategoryId;
                                        break;
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        return productCategoryId;
    }


    /**
     * SCIPIO: Returns appropriate trail elements for a category or ID (abstraction method), checked against current trail,
     * for passing to {@link #updateRequestAndTrail} method.
     * <p>
     * NOTE: The result is NOT necessarily a full trail, but a list of trail elems which will cause the subsequent
     * calls to make a full trail.
     * <p>
     * TODO: Modify doGet above to invoke this (too much variable reuse)
     * <p>
     * FIXME?: Currently this forces the trail to be a path under the top category in many circumstances, which is desirable in
     * some cases but not necessarily all. It does not take into account the existing trail.
     * This is generally not desirable - it defeats the Ofbiz schema and limits browsing - and is only acceptable
     * in very simple shops.
     */
    public static List<String> makeTrailElements(HttpServletRequest request, Delegator delegator, String categoryId, String productId) {
        List<String> trailElements = null;
        // FIXME?: Best lookup is currently not good enough... it also must be synchronized with #doFilter method.
        //trailElements = makeBestTrailElementsForTrail(request, delegator, categoryId, productId);
        if (trailElements == null) {
            trailElements = makeDefaultCategoryTrailElements(request, delegator, categoryId, productId);
        }
        return trailElements;
    }

    /**
     * SCIPIO: Tries to see if the passed product or category can be integrated in the current/given trail.
     * If it can, returns a list of TrailElements that can be passed to #updateRequestAndTrail which will merge
     * them together.
     * <p>
     * If an appropriate list of trail elements can't be made (a brand new one will be required), returns null.
     * <p>
     * FIXME: This still does not always produce the most appropriate default trail; it only helps in some
     * simple cases where current trail already contains the current product or category.
     */
    public static List<String> makeBestTrailElementsForTrail(HttpServletRequest request, Delegator delegator, String categoryId, String productId) {
        List<String> trail = CategoryWorker.getTrail(request);
        List<String> trailElements = null;

        if (categoryId != null) {
            if (trail == null || trail.size() < 1) {
                ;
            } else {
                // Find a category in the list which either equals us or of which we are a child
                ListIterator<String> li = trail.listIterator(trail.size());
                while(li.hasPrevious()) {
                    String trailCatId = li.previous();
                    if (categoryId.equals(trailCatId)) {
                        trailElements = new ArrayList<>();
                        trailElements.add(categoryId);
                        break;
                    }
                }
                if (trailElements == null) {
                    li = trail.listIterator(trail.size());
                    while(li.hasPrevious()) {
                        String trailCatId = li.previous();
                        if (CategoryWorker.isCategoryChildOf(request, trailCatId, categoryId)) {
                            trailElements = new ArrayList<>();
                            trailElements.add(trailCatId);
                            trailElements.add(categoryId);
                            break;
                        }
                    }
                }
            }
        } else {
            if (trail == null || trail.size() < 1) {
                ;
            } else {
                // check to see if there's a category in the trail that contains the product
                ListIterator<String> li = trail.listIterator(trail.size());
                while(li.hasPrevious()) {
                    String trailCatId = li.previous();
                    if (CategoryWorker.isCategoryContainsProduct(request, trailCatId, productId)) {
                        trailElements = new ArrayList<>();
                        trailElements.add(trailCatId);
                        break;
                    }
                }
            }
        }
        return trailElements;
    }

    /**
     * SCIPIO: Makes a fresh trail based on the default category for a product or category under the top catalog category.
     * Ignores the current trail.
     * Based on original {@link #doFilter} code.
     * May return null.
     */
    public static List<String> makeDefaultCategoryTrailElements(HttpServletRequest request, Delegator delegator, String categoryId, String productId) {

        String productCategoryId = categoryId;

        if (UtilValidate.isNotEmpty(productId)) {
            String catId = getProductDefaultCategoryId(delegator, productId);
            if (catId != null) {
                productCategoryId = catId;
            }
        }

        // generate trail belong to a top category
        // SCIPIO: 2017-08-15: this call is inappropriate; see method for details
        //String topCategoryId = CategoryWorker.getCatalogTopCategory(httpRequest, null);
        String topCategoryId = getCatalogTopCategory(request);
        List<GenericValue> trailCategories = CategoryWorker.getRelatedCategoriesRet(request, "trailCategories", topCategoryId, false, false, true);
        List<String> trailCategoryIds = EntityUtil.getFieldListFromEntityList(trailCategories, "productCategoryId", true);

        // look for productCategoryId from productId
        if (UtilValidate.isNotEmpty(productId)) {
            String catId = getProductMatchingCategoryId(delegator, productId, trailCategoryIds);
            if (catId != null) {
                productCategoryId = catId;
            }
        }

        if (UtilValidate.isNotEmpty(productCategoryId)) {
            List<String> trailElements = getTrailElements(delegator, productCategoryId, trailCategoryIds);

            // SCIPIO: NOTE: CatalogUrlFilter#doGet does another adjustment to trail
            // here before we add topCategoryId, but I don't know why, and I think it will
            // make no difference because we add topCategoryId now.

            if (trailElements.size() > 0) {
                trailElements.add(0, topCategoryId);
                return trailElements;
            }
        }
        return null;
    }

    /**
     * SCIPIO: Dedicated helper to get top category, from early filter call.
     * NOTE: This is MODIFIED from stock ofbiz behavior for several issues, see code below.
     * Added 2017-08-15.
     */
    public static String getCatalogTopCategory(ServletRequest request) {
        // SCIPIO: BUGFIX: 2017-08-15: in stock ofbiz, this filter was calling
        // CategoryWorker.getCatalogTopCategory to get the top category; but this does not
        // work for stores and was probably an error; we need the one from CatalogWorker
        //return CategoryWorker.getCatalogTopCategory(request, null);

        // TODO: REVIEW: originally switched to calling read-only here to avoid interfering with
        // any store initialization code; however by doing that we risk becoming out of sync
        // with the rest of the request; so now switched to calling the regular
        // catalogId/empty-trail save in session overload.
        //String catalogId = CatalogWorker.getCurrentCatalogIdReadOnly(request);
        String catalogId = CatalogWorker.getCurrentCatalogId(request);
        return CatalogWorker.getCatalogTopCategoryId(request, catalogId);
    }

    /**
     * SCIPIO: Stock code factored out from {@link #doFilter}.
     */
    public static String getProductDefaultCategoryId(Delegator delegator, String productId) {
        String productCategoryId = null;
        try {
            List<GenericValue> productCategoryMembers = EntityQuery.use(delegator).select("productCategoryId").from("ProductCategoryMember")
                    .where("productId", productId).orderBy("-fromDate").filterByDate().cache(true).queryList();
            if (UtilValidate.isNotEmpty(productCategoryMembers)) {
                GenericValue productCategoryMember = EntityUtil.getFirst(productCategoryMembers);
                productCategoryId = productCategoryMember.getString("productCategoryId");
            }
        } catch (GenericEntityException e) {
            Debug.logError(e, "Cannot find product category for product: " + productId, module);
        }
        return productCategoryId;
    }

    /**
     * SCIPIO: Stock code factored out from doGet.
     */
    public static String getProductMatchingCategoryId(Delegator delegator, String productId, List<String> categoryIds) {
        String productCategoryId = null;
        try {
            List<GenericValue> productCategoryMembers = EntityQuery.use(delegator).from("ProductCategoryMember")
                    .where("productId", productId).orderBy("-fromDate").filterByDate().cache(true).queryList();
            for (GenericValue productCategoryMember : productCategoryMembers) {
                String trailCategoryId = productCategoryMember.getString("productCategoryId");
                if (categoryIds.contains(trailCategoryId)) {
                    productCategoryId = trailCategoryId;
                    break;
                }
            }
        } catch (GenericEntityException e) {
            Debug.logError(e, "Cannot generate trail from product category", module);
        }
        return productCategoryId;
    }

    /**
     * SCIPIO: Stock code factored out from doGet.
     */
    public static List<String> getTrailElements(Delegator delegator, String productCategoryId, List<String> trailCategoryIds) {
        List<String> trailElements = new ArrayList<>();
        trailElements.add(productCategoryId);
        String parentProductCategoryId = productCategoryId;
        while (UtilValidate.isNotEmpty(parentProductCategoryId)) {
            // find product category rollup
            try {
                List<GenericValue> productCategoryRollups = EntityQuery.use(delegator).from("ProductCategoryRollup")
                        .where("productCategoryId", parentProductCategoryId).orderBy("-fromDate").filterByDate().cache(true).queryList();
                if (UtilValidate.isNotEmpty(productCategoryRollups)) {
                    // add only categories that belong to the top category to trail
                    for (GenericValue productCategoryRollup : productCategoryRollups) {
                        String trailCategoryId = productCategoryRollup.getString("parentProductCategoryId");
                        parentProductCategoryId = trailCategoryId;
                        if (trailCategoryIds.contains(trailCategoryId)) {
                            trailElements.add(trailCategoryId);
                            break;
                        }
                    }
                } else {
                    parentProductCategoryId = null;
                }
            } catch (GenericEntityException e) {
                Debug.logError(e, "Cannot generate trail from product category", module);
            }
        }
        Collections.reverse(trailElements);
        return trailElements;
    }

    /**
     * SCIPIO: Returns true if the given category or any of the path elements is a top-level category.
     */
    public static boolean hasTopCategory(HttpServletRequest request, String categoryId, List<String> pathElements) {
        if (CategoryWorker.isCategoryTop(request, categoryId)) {
            return true;
        }
        String topCategoryId = CategoryWorker.getTopCategoryFromTrail(request, pathElements);
        if (topCategoryId != null) {
            return true;
        }
        List<String> trail = CategoryWorker.getTrail(request);
        topCategoryId = CategoryWorker.getTopCategoryFromTrail(request, trail);
        if (topCategoryId == null) {
            return true;
        }
        return false;
    }


    /**
     * SCIPIO: Combines the getTrailElements, some extra fixups and updateRequestAndTrail.
     * Factored out from doFilter.
     * Added 2017-11-07.
     */
    public static void getTrailElementsAndUpdateRequestAndTrail(HttpServletRequest request, Delegator delegator, String productId,
            String productCategoryId, List<String> trailCategoryIds, String topCategoryId) {
        List<String> trailElements = getTrailElements(delegator, productCategoryId, trailCategoryIds);

        // SCIPIO: NOTE: Parts of this could reuse updateRequestAndTrail but there are minor difference,
        // not risking it for now.

        List<String> trail = CategoryWorker.getTrail(request);
        if (trail == null) {
            trail = new ArrayList<>();
        }

        // adjust trail
        String previousCategoryId = null;
        if (trail.size() > 0) {
            previousCategoryId = trail.get(trail.size() - 1);
        }
        trail = CategoryWorker.adjustTrail(trail, productCategoryId, previousCategoryId);

        // SCIPIO: 2016-03-23: There is a high risk here that the trail does not contain the top
        // category.
        // If top category is not in trail, we'll prepend it to trailElements.
        // This will cause the trailElements to replace the whole trail in the code that follows
        // because of the way setTrail with ID works.
        // I'm not sure what the intention of stock code was in these cases, but I think
        // this will simply prevent a lot of confusion and makes the trail more likely to be
        // a valid category path.
        // EDIT: This behavior is now changed, see next comment
        if (trailElements.size() > 0) {
            // SCIPIO: REVISION 2: we will ALWAYS add the top category to the trail
            // elements. It's needed because sometimes the code above will produce
            // entries incompatible with the current trail and it results in an
            // incomplete trail. Adding the top category should cause the code below
            // to reset much of the trail.
            //if (!trail.contains(topCategoryId)) {
            //    trailElements.add(0, topCategoryId);
            //}
            trailElements.add(0, topCategoryId);
        }

        // SCIPIO: this is now delegated
        updateRequestAndTrail(request, productCategoryId, productId, trailElements, trail);
    }

    /**
     * SCIPIO: Updates the trail elements using logic originally found in {@link CatalogUrlServlet#doGet}.
     * <p>
     * The caller should ensure the last path element is the same as the passed category ID.
     * <p>
     * trail is optional, will be fetched automatically.
     * <p>
     * NOTE: If non-null, the trail must be a copy already (not the unmodifiable original) - it may
     * be modified in-place by this method!
     */
    public static void updateRequestAndTrail(HttpServletRequest request, String categoryId, String productId, List<String> pathElements, List<String> trail) {
        if (UtilValidate.isEmpty(categoryId)) {
            categoryId = null;
        }
        if (UtilValidate.isEmpty(productId)) {
            productId = null;
        }

        if (pathElements != null) {

            // get category info going with the IDs that remain
            if (pathElements.size() == 1) {
                CategoryWorker.setTrail(request, pathElements.get(0), null);
                //categoryId = pathElements.get(0); // SCIPIO: Assume caller did this
            } else if (pathElements.size() == 2) {
                CategoryWorker.setTrail(request, pathElements.get(1), pathElements.get(0));
                //categoryId = pathElements.get(1); // SCIPIO: Assume caller did this
            } else if (pathElements.size() > 2) {
                // SCIPIO: 2018-11-28: Always make a trail copy, because unmodifiable
                if (trail == null) {
                    trail = CategoryWorker.getTrailCopy(request);
                    if (trail == null) {
                        trail = new ArrayList<>();
                    }
                // SCIPIO: Caller handles
                //} else {
                //    trail = new ArrayList<>(trail);
                //}
                }

                if (trail.contains(pathElements.get(0))) {
                    // first category is in the trail, so remove it everything after that and fill it in with the list from the pathInfo
                    int firstElementIndex = trail.indexOf(pathElements.get(0));
                    while (trail.size() > firstElementIndex) {
                        trail.remove(firstElementIndex);
                    }
                    trail.addAll(pathElements);
                } else {
                    // first category is NOT in the trail, so clear out the trail and use the pathElements list
                    trail.clear();
                    trail.addAll(pathElements);
                }
                CategoryWorker.setTrail(request, trail);
                //categoryId = pathElements.get(pathElements.size() - 1);  // SCIPIO: Assume caller did this
            }
        }

        // SCIPIO: Make sure we always reset this
        //if (categoryId != null) {
        request.setAttribute("productCategoryId", categoryId);
        //}

        String rootCategoryId = null;
        if (pathElements != null && pathElements.size() >= 1) {
            rootCategoryId = pathElements.get(0);
        }
        // SCIPIO: Make sure we always reset this
        //if (rootCategoryId != null) {
        request.setAttribute("rootCategoryId", rootCategoryId);
        //}

        if (productId != null) {
            request.setAttribute("product_id", productId);
            request.setAttribute("productId", productId);
        }

        request.setAttribute("categoryTrailUpdated", Boolean.TRUE); // SCIPIO: This is new
    }

    /**
     * SCIPIO: Checks if the current category and product was already processed for this request,
     * and if not, adjusts them in request and session (including trail).
     * Returns the categoryId.
     *
     * @see #getAdjustCurrentCategoryAndProduct(HttpServletRequest, String, String)
     */
    public static String getAdjustCurrentCategoryAndProduct(HttpServletRequest request, String productId) {
        return getAdjustCurrentCategoryAndProduct(request, productId, null);
    }

    /**
     * SCIPIO: Checks if the current category was already processed for this request,
     * and if not, adjusts them in request and session (including trail).
     * Returns the categoryId.
     *
     * @see #getAdjustCurrentCategoryAndProduct(HttpServletRequest, String, String)
     */
    public static String getAdjustCurrentCategory(HttpServletRequest request, String categoryId) {
        return getAdjustCurrentCategoryAndProduct(request, null, categoryId);
    }

    /**
     * SCIPIO: Checks if the current category and product was already processed for this request,
     * and if not, adjusts them in request and session (including trail).
     * Returns the categoryId.
     * <p>
     * This may be called from other events or screen actions where modifying request and session is safe.
     * <p>
     * If productId is empty, assumes dealing with categories only.
     * <p>
     * The passed productId should be the "main" product for the request. If no productId, then the
     * categoryId should be the "main" category for the request.
     * <p>
     * NOTE: This is an amalgamation of logic in {@link CatalogUrlFilter#doFilter} and {@link CatalogUrlServlet#doGet}.
     * <p>
     * FIXME?: Currently, like original CatalogUrlFilter, when trail was not already set or the existing one
     * is not quite valid, this will always produce
     * a trail based on product or category's default category under the catalog top category.
     * This is generally not desirable, but okay for simple shops. It may be better to produce a trail closer
     * to the current one (heuristically).
     */
    public static String getAdjustCurrentCategoryAndProduct(HttpServletRequest request, String productId, String categoryId) {
        Delegator delegator = (Delegator) request.getAttribute("delegator");

        String currentProductId = (String) request.getAttribute("productId");
        String currentCategoryId = (String) request.getAttribute("productCategoryId");

        if (UtilValidate.isEmpty(productId)) {
            productId = null;
        }
        if (UtilValidate.isEmpty(categoryId)) {
            categoryId = null;
        }
        if (UtilValidate.isEmpty(currentProductId)) {
            currentProductId = null;
        }
        if (UtilValidate.isEmpty(currentCategoryId)) {
            currentCategoryId = null;
        }

        // Just use a dedicated flag instead of this. Much less likely to conflict with other code.
        //// Generally, we want to enter this if the current category is not set. If it's set it means we already did this call (or an equivalent one) somewhere.
        //if (currentCategoryId == null ||
        //    (!currentCategoryId.equals(categoryId)) || // This shouldn't really happen, but can deal with it for free
        //    (productId != null && !productId.equals(currentProductId))) {
        if (!Boolean.TRUE.equals(request.getAttribute("categoryTrailUpdated"))) {

            if (categoryId != null || productId != null) {
                // NOTE: We only reuse the current category ID, not product ID, because if caller passed productId null it means
                // we're only doing categories.
                if (categoryId == null) {
                    categoryId = currentCategoryId;
                }

                List<String> trailElements = CatalogUrlFilter.makeTrailElements(request, delegator, categoryId, productId);

                if (trailElements != null && trailElements.size() >= 1) {
                    categoryId = trailElements.get(trailElements.size() - 1);
                }

                updateRequestAndTrail(request, categoryId, productId, trailElements, null);
                currentCategoryId = categoryId;
                currentProductId = productId;
            } else {
                Debug.logWarning("Scipio: Cannot adjust current product or category in request; neither was specified", module);
            }
        }
        return currentCategoryId;
    }


    public static String makeCategoryUrl(HttpServletRequest request, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString) {
        Delegator delegator = (Delegator) request.getAttribute("delegator");
        try {
            GenericValue productCategory = EntityQuery.use(delegator).from("ProductCategory").where("productCategoryId", productCategoryId).cache().queryOne();
            CategoryContentWrapper wrapper = new CategoryContentWrapper(productCategory, request);
            List<String> trail = CategoryWorker.getTrail(request);
            return makeCategoryUrl(delegator, wrapper, trail, request.getContextPath(), previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
        } catch (GenericEntityException e) {
            Debug.logWarning(e, "Cannot create category's URL for: " + productCategoryId, module);
            return redirectUrl;
        }
    }

    public static String makeCategoryUrl(Delegator delegator, CategoryContentWrapper wrapper, List<String> trail, String contextPath, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString) {
        String url = "";
        String alternativeUrl = wrapper.get("ALTERNATIVE_URL", "url"); // SCIPIO: now returns regular String

        if (UtilValidate.isNotEmpty(alternativeUrl)) {
            StringBuilder urlBuilder = new StringBuilder();
            urlBuilder.append(contextPath);
            if (urlBuilder.length() == 0 || urlBuilder.charAt(urlBuilder.length() - 1) != '/') {
                urlBuilder.append("/");
            }
            // append alternative URL
            url = UrlServletHelper.invalidCharacter(alternativeUrl.toString());
            urlBuilder.append(url);
            if (UtilValidate.isNotEmpty(productCategoryId)) {
                urlBuilder.append("-");
                urlBuilder.append(productCategoryId);
                urlBuilder.append("-c");
            }
            // append view index
            if (UtilValidate.isNotEmpty(viewIndex)) {
                if (!urlBuilder.toString().endsWith("?") && !urlBuilder.toString().endsWith("&")) {
                    urlBuilder.append("?");
                }
                urlBuilder.append("viewIndex=" + viewIndex + "&");
            }
            // append view size
            if (UtilValidate.isNotEmpty(viewSize)) {
                if (!urlBuilder.toString().endsWith("?") && !urlBuilder.toString().endsWith("&")) {
                    urlBuilder.append("?");
                }
                urlBuilder.append("viewSize=" + viewSize + "&");
            }
            // append view sort
            if (UtilValidate.isNotEmpty(viewSort)) {
                if (!urlBuilder.toString().endsWith("?") && !urlBuilder.toString().endsWith("&")) {
                    urlBuilder.append("?");
                }
                urlBuilder.append("viewSort=" + viewSort + "&");
            }
            // append search string
            if (UtilValidate.isNotEmpty(searchString)) {
                if (!urlBuilder.toString().endsWith("?") && !urlBuilder.toString().endsWith("&")) {
                    urlBuilder.append("?");
                }
                urlBuilder.append("searchString=" + searchString + "&");
            }
            if (urlBuilder.toString().endsWith("&")) {
                return urlBuilder.toString().substring(0, urlBuilder.toString().length()-1);
            }

            url = urlBuilder.toString();
        } else {
            if (UtilValidate.isEmpty(trail)) {
                trail = new ArrayList<>();
            }
            url = CatalogUrlServlet.makeCatalogUrl(contextPath, trail, productId, productCategoryId, previousCategoryId);
        }

        return url;
    }

    public static String makeProductUrl(HttpServletRequest request, String previousCategoryId, String productCategoryId, String productId) {
        Delegator delegator = (Delegator) request.getAttribute("delegator");
        String url = null;
        try {
            GenericValue product = EntityQuery.use(delegator).from("Product").where("productId", productId).cache().queryOne();
            ProductContentWrapper wrapper = new ProductContentWrapper(product, request);
            List<String> trail = CategoryWorker.getTrail(request);
            url = makeProductUrl(delegator, wrapper, trail, request.getContextPath(), previousCategoryId, productCategoryId, productId);
        } catch (GenericEntityException e) {
            Debug.logWarning(e, "Cannot create product's URL for: " + productId, module);
            return redirectUrl;
        }
        return url;
    }

    public static String makeProductUrl(Delegator delegator, ProductContentWrapper wrapper, List<String> trail, String contextPath, String previousCategoryId, String productCategoryId, String productId) {
        String url = "";
        String alternativeUrl = wrapper.get("ALTERNATIVE_URL", "url");  // SCIPIO: now returns regular String
        if (UtilValidate.isNotEmpty(alternativeUrl)) {
            StringBuilder urlBuilder = new StringBuilder();
            urlBuilder.append(contextPath);
            if (urlBuilder.length() == 0 || urlBuilder.charAt(urlBuilder.length() - 1) != '/') {
                urlBuilder.append("/");
            }
            // append alternative URL
            url = UrlServletHelper.invalidCharacter(alternativeUrl.toString());
            urlBuilder.append(url);
            if (UtilValidate.isNotEmpty(productId)) {
                urlBuilder.append("-");
                urlBuilder.append(productId);
                urlBuilder.append("-p");
            }
            url = urlBuilder.toString();
        } else {
            if (UtilValidate.isEmpty(trail)) {
                trail = new ArrayList<>();
            }
            url = CatalogUrlServlet.makeCatalogUrl(contextPath, trail, productId, productCategoryId, previousCategoryId);
        }
        return url;
    }

    public static String getDefaultLocaleString() {
        return defaultLocaleString;
    }

    public static void setDefaultLocaleString(String defaultLocaleString) {
        CatalogUrlFilter.defaultLocaleString = defaultLocaleString;
    }

    public static String getRedirectUrl() {
        return redirectUrl;
    }

    public static void setRedirectUrl(String redirectUrl) {
        CatalogUrlFilter.redirectUrl = redirectUrl;
    }

    /**
     * SCIPIO: NEW, FULLY-FEATURED java-frontend catalog link building method, that passes everything through
     * request encoding and supports everything that <code>@catalogAltUrl</code> FTL macro supports.
     * <p>
     * This version supports a webSiteId and contextPath that, if specified, will turn the link-building into an
     * inter-webapp mode that avoids use of session information.
     * NOTE: it will do this even if the passed webSiteId is the same as the one of current request
     * (there is intentionally no check for this, so the parameter has a double function).
     * If contextPath is omitted, it is determined automatically from webSiteId.
     * It is preferable to use webSiteId where possible.
     * <p>
     * 2017-11: This method will now automatically implement the alternative SEO link building.
     * NOTE: 2017-11-06: this overload requires explicit locale - it does NOT use locale from request.
     */
    public static String makeCatalogAltLink(HttpServletRequest request, HttpServletResponse response, Locale locale, String productCategoryId, String productId,
            String previousCategoryId, Object params, FullWebappInfo targetWebappInfo, Boolean fullPath, Boolean secure, Boolean encode,
            String viewSize, String viewIndex, String viewSort, String searchString) {
        FullWebappInfo builderWebappInfo;
        try {
            builderWebappInfo = (targetWebappInfo != null) ? targetWebappInfo : FullWebappInfo.fromRequest(request);
        } catch (Exception e) {
            Debug.logError("makeCatalogAltLink: Could not get current webapp info from request: " + e.toString(), module);
            return null;
        }
        CatalogAltUrlBuilder builder = CatalogAltUrlBuilder.getBuilder((Delegator) request.getAttribute("delegator"), builderWebappInfo);
        return builder.makeCatalogAltLink(request, response, locale, productCategoryId, productId, previousCategoryId, params, targetWebappInfo, fullPath, secure, encode, viewSize, viewIndex, viewSort, searchString);
    }

    /**
     * SCIPIO: NEW, FULLY-FEATURED java-frontend catalog link building method, that passes everything through
     * request encoding and supports everything that <code>@catalogAltUrl</code> FTL macro supports.
     * <p>
     * This version assumes the current webapp is the target webapp and may use session information.
     */
    public static String makeCatalogAltLink(HttpServletRequest request, HttpServletResponse response, Locale locale, String productCategoryId, String productId, String previousCategoryId,
            Object params, Boolean fullPath, Boolean secure, Boolean encode,
            String viewSize, String viewIndex, String viewSort, String searchString) {
        return makeCatalogAltLink(request, response, locale, productCategoryId, productId, previousCategoryId, params, null, fullPath, secure, encode, viewSize, viewIndex, viewSort, searchString);
    }

    /**
     * SCIPIO: NEW, FULLY-FEATURED java-frontend catalog link building method, that passes everything through
     * request encoding and supports everything that <code>@catalogAltUrl</code> FTL macro supports.
     * <p>
     * This builds the link in a completely static, inter-webapp way, using no request information.
     * <p>
     * NOTE: if contextPath is omitted (null), it will be determined automatically.
     */
    public static String makeCatalogAltLink(Map<String, Object> context, Delegator delegator, LocalDispatcher dispatcher, Locale locale, String productCategoryId, String productId,
            String previousCategoryId, Object params, FullWebappInfo targetWebappInfo, Boolean fullPath, Boolean secure,
            String viewSize, String viewIndex, String viewSort, String searchString) {
        return makeCatalogAltLink(context, delegator, dispatcher, locale, productCategoryId, productId, previousCategoryId, params, targetWebappInfo, fullPath, secure, null,
                viewSize, viewIndex, viewSort, searchString);
    }

    /**
     * SCIPIO: NEW, FULLY-FEATURED java-frontend catalog link building method, that passes everything through
     * request encoding and supports everything that <code>@catalogAltUrl</code> FTL macro supports.
     * <p>
     * This builds the link in a completely static, inter-webapp way, using no request information, but may also optionally encode
     * the resulting link.
     * <p>
     * NOTE: if contextPath is omitted (null), it will be determined automatically.
     * <p>
     * 2017-11: This method will now automatically implement the alternative SEO link building.
     */
    public static String makeCatalogAltLink(Map<String, Object> context, Delegator delegator, LocalDispatcher dispatcher, Locale locale, String productCategoryId, String productId,
            String previousCategoryId, Object params, FullWebappInfo targetWebappInfo, Boolean fullPath, Boolean secure, Boolean encode,
            String viewSize, String viewIndex, String viewSort, String searchString) {
        FullWebappInfo currentWebappInfo;
        try {
            currentWebappInfo = FullWebappInfo.fromContext(context);
        } catch (Exception e) {
            Debug.logError("makeCatalogAltLink: Could not get current webapp info from context: " + e.toString(), module);
            return null;
        }
        CatalogAltUrlBuilder builder = CatalogAltUrlBuilder.getBuilder(delegator, (targetWebappInfo != null) ? targetWebappInfo : currentWebappInfo);
        return builder.makeCatalogAltLink(context, delegator, dispatcher, locale, null, productCategoryId, productId, previousCategoryId, params,
                targetWebappInfo, null, fullPath, secure, encode, viewSize, viewIndex, viewSort, searchString, currentWebappInfo);
    }

    /**
     * SCIPIO: 2017: Wraps all the category URL method calls so they can be switched out without
     * ruining the code.
     */
    public static abstract class CatalogAltUrlBuilder {

        /**
         * SCIPIO: 2017: allows plugging in custom low-level URL builders.
         * FIXME: poor initialization logic
         */
        private static List<CatalogAltUrlBuilder.Factory> urlBuilderFactories = Collections.emptyList();
        private static final Object urlBuilderFactoriesSyncObj = new Object();

        public static CatalogAltUrlBuilder getDefaultBuilder() {
            return OfbizCatalogAltUrlBuilder.getInstance();
        }

        protected static CatalogAltUrlBuilder getBuilder(Delegator delegator, FullWebappInfo targetWebappInfo) {
            for(CatalogAltUrlBuilder.Factory factory : urlBuilderFactories) {
                CatalogAltUrlBuilder builder = factory.getCatalogAltUrlBuilder(delegator, targetWebappInfo);
                if (builder != null) return builder;
            }
            return getDefaultBuilder();
        }

        public static void registerUrlBuilder(String name, CatalogAltUrlBuilder.Factory builderFactory) {
            if (urlBuilderFactories.contains(builderFactory)) return;
            synchronized (urlBuilderFactoriesSyncObj) {
                if (urlBuilderFactories.contains(builderFactory)) return;
                List<CatalogAltUrlBuilder.Factory> newList = new ArrayList<>(urlBuilderFactories);
                newList.add(builderFactory);
                urlBuilderFactories = Collections.unmodifiableList(newList);
            }
        }

        public interface Factory {
            /**
             * Returns builder or null if not applicable to request.
             */
            CatalogAltUrlBuilder getCatalogAltUrlBuilder(Delegator delegator, FullWebappInfo targetWebappInfo);
        }

        // low-level building methods (named after legacy ofbiz methods)
        public abstract String makeProductAltUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId, String productId);
        public abstract String makeProductAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail, FullWebappInfo targetWebappInfo, String currentCatalogId, String previousCategoryId, String productCategoryId, String productId);
        public abstract String makeCategoryAltUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString);
        public abstract String makeCategoryAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail, FullWebappInfo targetWebappInfo, String currentCatalogId, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString);

        /**
         * Common/default high-level makeCatalogAltLink implementation (new Scipio method).
         */
        public String makeCatalogAltLink(HttpServletRequest request, HttpServletResponse response, Locale locale, String productCategoryId, String productId,
                String previousCategoryId, Object params, FullWebappInfo targetWebappInfo,
                Boolean fullPath, Boolean secure, Boolean encode,
                String viewSize, String viewIndex, String viewSort, String searchString) {
            // SCIPIO: 2017-11-06: NOT doing this here - caller or other overloads should do.
            //if (locale == null) {
            //    locale = UtilHttp.getLocale(request);
            //}

            String url;
            if (targetWebappInfo != null) {
                // SPECIAL CASE: if there is a specific target webapp, we must NOT use the current session stuff,
                // and build as if we had no request
                Delegator delegator = (Delegator) request.getAttribute("delegator");
                LocalDispatcher dispatcher = (LocalDispatcher) request.getAttribute("dispatcher");
                String currentCatalogId = CatalogWorker.getCurrentCatalogId(request);

                if (UtilValidate.isNotEmpty(productId)) {
                    url = this.makeProductAltUrl(delegator, dispatcher, locale, CategoryWorker.getTrail(request), targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId);
                } else {
                    url = this.makeCategoryAltUrl(delegator, dispatcher, locale, CategoryWorker.getTrail(request), targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
                }
            } else {
                if (UtilValidate.isNotEmpty(productId)) {
                    url = this.makeProductAltUrl(request, locale, previousCategoryId, productCategoryId, productId);
                } else {
                    url = this.makeCategoryAltUrl(request, locale, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
                }
            }
            if (url == null) {
                return null;
            }
            url = appendLinkParams(url, params);
            return RequestLinkUtil.buildLinkHostPartAndEncode(request, response, locale, targetWebappInfo, url, fullPath, secure, encode, true);
        }

        /**
         * Common/default high-level makeCatalogAltLink implementation (new Scipio method).
         */
        public String makeCatalogAltLink(Map<String, Object> context, Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail,
                String productCategoryId, String productId, String previousCategoryId, Object params,
                FullWebappInfo targetWebappInfo, String currentCatalogId, Boolean fullPath, Boolean secure, Boolean encode,
                String viewSize, String viewIndex, String viewSort, String searchString,
                FullWebappInfo currentWebappInfo) {
            if (targetWebappInfo == null) {
                targetWebappInfo = currentWebappInfo;
                if (targetWebappInfo == null) {
                    Debug.logError("makeCatalogAltLink: Cannot build link: No target webapp specified and no current webapp could be determined (from context)", module);
                    return null;
                }
            }

            String url;
            if (UtilValidate.isNotEmpty(productId)) {
                url = this.makeProductAltUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId);
            } else {
                url = this.makeCategoryAltUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
            }
            if (url == null) {
                return null;
            }
            url = appendLinkParams(url, params);
            return RequestLinkUtil.buildLinkHostPartAndEncode(delegator, locale, targetWebappInfo, url, fullPath, secure, encode, true, currentWebappInfo, context);
        }

        /**
         * Implements the stock ofbiz catalog alt URLs.
         */
        public static class OfbizCatalogAltUrlBuilder extends CatalogAltUrlBuilder {
            private static final OfbizCatalogAltUrlBuilder INSTANCE = new OfbizCatalogAltUrlBuilder();

            public static final OfbizCatalogAltUrlBuilder getInstance() { return INSTANCE; }

            @Override
            public String makeProductAltUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId,
                    String productId) {
                return CatalogUrlFilter.makeProductUrl(request, previousCategoryId, productCategoryId, productId);
            }
            @Override
            public String makeProductAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail,
                    FullWebappInfo targetWebappInfo, String currentCatalogId, String previousCategoryId, String productCategoryId, String productId) {

                GenericValue product;
                try {
                    product = EntityQuery.use(delegator).from("Product").where("productId", productId).queryOne();
                } catch (GenericEntityException e) {
                    throw new RuntimeException(e);
                }
                // SCIPIO: 2017-11-07: CHANGED MIME-TYPE:
                // this should not be text/html. this should also be unrelated to escaping. there's no html here.
                //ProductContentWrapper wrapper = new ProductContentWrapper(dispatcher, product, locale, "text/html");
                ProductContentWrapper wrapper = new ProductContentWrapper(dispatcher, product, locale, "text/plain");

                return CatalogUrlFilter.makeProductUrl(delegator, wrapper, trail, targetWebappInfo.getContextPath(), previousCategoryId, productCategoryId, productId);
            }
            @Override
            public String makeCategoryAltUrl(HttpServletRequest request, Locale locale, String previousCategoryId,
                    String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort,
                    String searchString) {
                return CatalogUrlFilter.makeCategoryUrl(request, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
            }
            @Override
            public String makeCategoryAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail,
                    FullWebappInfo targetWebappInfo, String currentCatalogId, String previousCategoryId, String productCategoryId, String productId,
                    String viewSize, String viewIndex, String viewSort, String searchString) {

                GenericValue productCategory;
                try {
                    productCategory = EntityQuery.use(delegator).from("ProductCategory").where("productCategoryId", productCategoryId).queryOne();
                } catch (GenericEntityException e) {
                    throw new RuntimeException(e);
                }
                // SCIPIO: 2017-11-07: CHANGED MIME-TYPE:
                // this should not be text/html. this should also be unrelated to escaping. there's no html here.
                //CategoryContentWrapper wrapper = new CategoryContentWrapper(dispatcher, productCategory, locale, "text/html");
                CategoryContentWrapper wrapper = new CategoryContentWrapper(dispatcher, productCategory, locale, "text/plain");

                return CatalogUrlFilter.makeCategoryUrl(delegator, wrapper, trail, targetWebappInfo.getContextPath(), previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
            }
        }

        /**
         * Appends params for catalog URLs.
         * <p>
         * WARN: this currently assumes the url contains no params, could change in future
         */
        protected static String appendLinkParams(String url, Object paramsObj) {
            return CatalogUrlBuilder.appendLinkParams(url, paramsObj);
        }
    }

}