applications/product/src/com/ilscipio/scipio/product/seo/SeoCatalogUrlWorker.java
/*******************************************************************************
* 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 com.ilscipio.scipio.product.seo;
import java.io.Serializable;
import java.sql.Timestamp;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Locale;
import java.util.Map;
import java.util.ResourceBundle;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.servlet.http.HttpServletRequest;
import org.ofbiz.base.util.Debug;
import org.ofbiz.base.util.StringUtil;
import org.ofbiz.base.util.UtilDateTime;
import org.ofbiz.base.util.UtilHttp;
import org.ofbiz.base.util.UtilMisc;
import org.ofbiz.base.util.UtilProperties;
import org.ofbiz.base.util.UtilValidate;
import org.ofbiz.base.util.cache.UtilCache;
import org.ofbiz.entity.Delegator;
import org.ofbiz.entity.GenericEntityException;
import org.ofbiz.entity.GenericValue;
import org.ofbiz.entity.condition.EntityCondition;
import org.ofbiz.entity.condition.EntityOperator;
import org.ofbiz.entity.util.EntityQuery;
import org.ofbiz.entity.util.EntityUtil;
import org.ofbiz.product.catalog.CatalogWorker;
import org.ofbiz.product.category.CatalogUrlFilter.CatalogAltUrlBuilder;
import org.ofbiz.product.category.CatalogUrlServlet.CatalogUrlBuilder;
import org.ofbiz.product.category.CategoryContentWrapper;
import org.ofbiz.product.category.CategoryWorker;
import org.ofbiz.product.product.ProductContentWrapper;
import org.ofbiz.product.product.ProductWorker;
import org.ofbiz.service.LocalDispatcher;
import org.ofbiz.webapp.FullWebappInfo;
import com.ilscipio.scipio.ce.util.SeoStringUtil;
import com.ilscipio.scipio.product.category.CatalogAltUrlSanitizer;
import com.ilscipio.scipio.product.category.CatalogUrlType;
/**
* SEO url building functions and callbacks.
*
* <p>Some parts adapted from the original <code>org.ofbiz.product.category.ftl.CatalogUrlSeoTransform</code>;
* others re-done based on {@link org.ofbiz.product.category.CatalogUrlFilter}.</p>
*
* <p><strong>WARN:</strong> Do not call makeXxxUrl methods from this class from client code!
* Client code that need java methods should use (which these plug into):</p>
* <ul>
* <li>{@link org.ofbiz.product.category.CatalogUrlFilter#makeCatalogAltLink}</li>
* <li>{@link org.ofbiz.product.category.CatalogUrlServlet#makeCatalogLink}</li>
* </ul>
*
* <p>FIXME: makeXxxUrlPath methods do not respect useCache flag</p>
*
* <p>SCIPIO: 3.0.0: Enhanced inbound URL matching logic, lookups and heuristic.</p>
*/
@SuppressWarnings("serial")
public class SeoCatalogUrlWorker implements Serializable {
private static final Debug.OfbizLogger module = Debug.getOfbizLogger(java.lang.invoke.MethodHandles.lookup().lookupClass());
public static final String DEFAULT_CONFIG_RESOURCE = "SeoConfigUiLabels";
public static final boolean DEBUG = false;
// TODO: in production, these cache can be tweaked with non-soft refs, limits and expire time
private static final UtilCache<String, PathSegmentEntities> productPathSegmentEntitiesCache = UtilCache.createUtilCache("seo.filter.product.pathsegment.entities", true);
//private static final long productPathSegmentEntitiesCacheExpireTimeMs = productPathSegmentEntitiesCache.getExpireTime();
private static final UtilCache<String, PathSegmentEntities> categoryPathSegmentEntitiesCache = UtilCache.createUtilCache("seo.filter.category.pathsegment.entities", true);
//private static final long categoryPathSegmentEntitiesCacheExpireTimeMs = categoryPathSegmentEntitiesCache.getExpireTime();
private static final UtilCache<String, String> productUrlCache = UtilCache.createUtilCache("seo.filter.product.url", true);
private static final UtilCache<String, TrailCacheEntry> productTrailCache = UtilCache.createUtilCache("seo.filter.product.trails", true);
private static final UtilCache<String, String> categoryUrlCache = UtilCache.createUtilCache("seo.filter.category.url", true);
private static final UtilCache<String, TrailCacheEntry> categoryTrailCache = UtilCache.createUtilCache("seo.filter.category.trails", true);
private static final EntityCondition productContentTypeIdAltUrlCond = EntityCondition.makeCondition("productContentTypeId", "ALTERNATIVE_URL");
private static final EntityCondition contentAssocTypeIdAltLocaleCond = EntityCondition.makeCondition("contentAssocTypeId", "ALTERNATE_LOCALE");
private static final Set<String> productContentAndElecTextShortMinimalSelectFields = UtilMisc.toSet("productId", "textData", "localeString", "contentId", "dataResourceId", "fromDate", "thruDate");
private static final Set<String> productContentAssocAndElecTextShortMinimalSelectFields = UtilMisc.toSet("productId", "textData", "localeString", "contentId", "dataResourceId", "fromDate", "thruDate", "caFromDate", "caThruDate");
private static final EntityCondition prodCatContentTypeIdAltUrlCond = EntityCondition.makeCondition("prodCatContentTypeId", "ALTERNATIVE_URL");
private static final Set<String> productCategoryContentAndElecTextShortMinimalSelectFields = UtilMisc.toSet("productCategoryId", "textData", "localeString", "contentId", "dataResourceId", "fromDate", "thruDate");
private static final Set<String> productCategoryContentAssocAndElecTextShortMinimalSelectFields = UtilMisc.toSet("productCategoryId", "textData", "localeString", "contentId", "dataResourceId", "fromDate", "thruDate", "caFromDate", "caThruDate");
protected static class TrailCacheEntry implements Serializable {
protected final Set<String> topCategoryIds;
protected final List<List<String>> trails;
protected TrailCacheEntry(Set<String> topCategoryIds, List<List<String>> trails) {
this.topCategoryIds = topCategoryIds;
this.trails = (trails != null) ? trails : Collections.emptyList();
}
public Set<String> getTopCategoryIds() { return topCategoryIds; }
public List<List<String>> getTrails() { return trails; }
}
static {
CatalogUrlBuilder.registerUrlBuilder("seo", BuilderFactory.getInstance());
CatalogAltUrlBuilder.registerUrlBuilder("seo", BuilderFactory.getInstance());
}
public static void initStatic() {
// formality
}
private static final class Instances {
private static final SeoCatalogUrlWorker DEFAULT = new SeoCatalogUrlWorker();
}
// trying to avoid this if possible... if needed, has to be configurable
// /**
// * FIXME: unhardcode; could be per-store.
// */
// private static final List<String> DEFAULT_BROWSABLE_ROOTCATTYPES = UtilMisc.unmodifiableArrayList(
// "PCCT_BROWSE_ROOT", "PCCT_PROMOTIONS", "PCCT_BEST_SELL"
// );
/*
* *****************************************************
* Fields
* *****************************************************
*/
public enum UrlType {
PRODUCT,
CATEGORY;
// TODO?: FUTURE: CONTENT
}
protected SeoConfig config;
// TODO: intended for later
//protected final String webSiteId;
//protected final String contextPath;
protected final String configResourceName;
// lazy load, these do not require sync
protected LocalizedName productPathName = null;
protected LocalizedName categoryPathName = null;
// kludge for no multiple inheritance
protected final SeoCatalogUrlBuilder catalogUrlBuilder;
protected final SeoCatalogAltUrlBuilder catalogAltUrlBuilder;
protected final CatalogAltUrlSanitizer catalogAltUrlSanitizer;
/*
* *****************************************************
* Constructors and factories
* *****************************************************
*/
protected SeoCatalogUrlWorker(SeoConfig config) {
this.config = config;
this.configResourceName = DEFAULT_CONFIG_RESOURCE;
this.catalogUrlBuilder = createCatalogUrlBuilder();
this.catalogAltUrlBuilder = createCatalogAltUrlBuilder();
this.catalogAltUrlSanitizer = createCatalogAltUrlSanitizer();
}
protected SeoCatalogUrlWorker() {
this(SeoConfig.getCommonConfig());
}
public static class Factory<T extends SeoCatalogUrlWorker> implements Serializable {
protected static final Factory<SeoCatalogUrlWorker> DEFAULT = new Factory<>();
public static Factory<SeoCatalogUrlWorker> getDefault() { return DEFAULT; }
public SeoCatalogUrlWorker getUrlWorker(SeoConfig config) {
return new SeoCatalogUrlWorker(config);
}
}
protected SeoCatalogUrlBuilder createCatalogUrlBuilder() {
return new SeoCatalogUrlBuilder();
}
protected SeoCatalogAltUrlBuilder createCatalogAltUrlBuilder() {
return new SeoCatalogAltUrlBuilder();
}
protected CatalogAltUrlSanitizer createCatalogAltUrlSanitizer() {
return new SeoCatalogAltUrlSanitizer();
}
/**
* Returns an instance with possible website-specific configuration.
*/
public static SeoCatalogUrlWorker getInstance(Delegator delegator, String webSiteId) {
return getInstance(SeoConfig.getConfig(delegator, webSiteId), delegator, webSiteId);
}
/**
* Returns an instance with possible website-specific configuration.
*/
public static SeoCatalogUrlWorker getInstance(SeoConfig config, Delegator delegator, String webSiteId) {
return config.getUrlWorker();
}
/**
* Returns an instance that is UNABLE to perform website-specific operations.
*/
public static SeoCatalogUrlWorker getDefaultInstance(Delegator delegator) {
if (SeoConfig.DEBUG_FORCERELOAD) return createInstanceDeep(delegator, null);
else return Instances.DEFAULT;
}
/**
* Force create new instance - for debugging only!
*/
public static SeoCatalogUrlWorker createInstance(Delegator delegator, String webSiteId) {
SeoConfig config = SeoConfig.getConfig(delegator, webSiteId);
return config.getUrlWorkerFactory().getUrlWorker(config);
}
/**
* Force create new instance deep - for debugging only!
*/
public static SeoCatalogUrlWorker createInstanceDeep(Delegator delegator, String webSiteId) {
return new SeoCatalogUrlWorker(SeoConfig.createConfig(delegator, webSiteId));
}
/**
* Boilerplate factory that returns builder instances for the CatalogUrlFilter/CatalogUrlServlet builder registry.
* Methods return null if SEO not enabled for the webSiteId/contextPath.
*/
public static class BuilderFactory implements CatalogAltUrlBuilder.Factory, CatalogUrlBuilder.Factory {
private static final BuilderFactory INSTANCE = new BuilderFactory();
public static BuilderFactory getInstance() { return INSTANCE; }
@Override
public CatalogUrlBuilder getCatalogUrlBuilder(Delegator delegator, FullWebappInfo targetWebappInfo) {
SeoConfig config = SeoConfig.getConfig(delegator, targetWebappInfo);
if (!config.isSeoUrlEnabled(targetWebappInfo.getContextPath(), targetWebappInfo.getWebSiteId())) return null;
return SeoCatalogUrlWorker.getInstance(config, delegator, targetWebappInfo.getWebSiteId()).getCatalogUrlBuilder();
}
@Override
public CatalogAltUrlBuilder getCatalogAltUrlBuilder(Delegator delegator, FullWebappInfo targetWebappInfo) {
SeoConfig config = SeoConfig.getConfig(delegator, targetWebappInfo);
if (!config.isSeoUrlEnabled(targetWebappInfo.getContextPath(), targetWebappInfo.getWebSiteId())) return null;
return SeoCatalogUrlWorker.getInstance(config, delegator, targetWebappInfo.getWebSiteId()).getCatalogAltUrlBuilder();
}
}
/*
* *****************************************************
* Getters and config
* *****************************************************
*/
public SeoConfig getConfig() {
return config;
}
/**
* NOTE: This can return null.
*/
public String getWebSiteId() {
return getConfig().getWebSiteId();
}
@Deprecated
protected Locale getDefaultLocale() {
return Locale.getDefault();
}
public String getConfigResourceName() {
return configResourceName;
}
protected LocalizedName getProductPathName() {
LocalizedName productPathName = this.productPathName;
if (productPathName == null) {
productPathName = LocalizedName.getNormalizedFromProperties(getConfigResourceName(), "SeoConfigPathNameProduct");
this.productPathName = productPathName;
}
return productPathName;
}
public String getProductServletPathName(Locale locale) {
return getProductPathName().getNameForLocaleOrDefault(locale);
//return UtilProperties.getMessage(getConfigResourceName(), "SeoConfigPathNameProduct", locale);
}
public String getProductServletPath(Locale locale) {
return "/" + getProductServletPathName(locale);
}
public Locale getProductServletPathNameLocale(String pathName) {
return getProductPathName().getLocaleForName(pathName);
}
protected LocalizedName getCategoryPathName() {
LocalizedName categoryPathName = this.categoryPathName;
if (categoryPathName == null) {
categoryPathName = LocalizedName.getNormalizedFromProperties(getConfigResourceName(), "SeoConfigPathNameCategory");
this.categoryPathName = categoryPathName;
}
return categoryPathName;
}
public String getCategoryServletPathName(Locale locale) {
return getCategoryPathName().getNameForLocaleOrDefault(locale);
//return UtilProperties.getMessage(getConfigResourceName(), "SeoConfigPathNameCategory", locale);
}
@Deprecated
public String getCategoryServletPathName() {
return getCategoryServletPathName(getDefaultLocale());
}
public String getCategoryServletPath(Locale locale) {
return "/" + getCategoryServletPathName(locale);
}
@Deprecated
public String getCategoryServletPath() {
return getCategoryServletPath(getDefaultLocale());
}
public Locale getCategoryServletPathNameLocale(String pathName) {
return getCategoryPathName().getLocaleForName(pathName);
}
public String getUrlSuffix() {
return config.getSeoUrlSuffix();
}
// public String extractProductServletPrefix(String path) {
// throw new UnsupportedOperationException(); // TODO: if needed
// }
//
// public String extractCategoryServletPrefix(String path) {
// throw new UnsupportedOperationException(); // TODO: if needed
// }
/*
* *****************************************************
* High-level helper methods (for INTERNAL use)
* *****************************************************
*/
/**
* Re-generates a link from PathMatch info (TODO).
*/
public String makeCatalogLink(Delegator delegator, PathMatch urlInfo, Locale locale) {
throw new UnsupportedOperationException("makeCatalogLink for PathMatch not yet implemented");
}
/*
* *****************************************************
* Low-level URL building callbacks from makeCatalog[Alt]Link
* *****************************************************
*/
public CatalogUrlBuilder getCatalogUrlBuilder() {
return catalogUrlBuilder;
}
public class SeoCatalogUrlBuilder extends CatalogUrlBuilder implements Serializable {
@Override
public String makeCatalogUrl(HttpServletRequest request, Locale locale, String productId, String currentCategoryId,
String previousCategoryId) {
if (UtilValidate.isNotEmpty(productId)) {
return makeProductUrl(request, locale, previousCategoryId, currentCategoryId, productId);
} else {
return makeCategoryUrl(request, locale, previousCategoryId, currentCategoryId, productId, null, null, null, null);
}
//return CatalogUrlBuilder.getDefaultBuilder().makeCatalogUrl(request, locale, productId, currentCategoryId, previousCategoryId);
}
@Override
public String makeCatalogUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, FullWebappInfo targetWebappInfo, String currentCatalogId, List<String> crumb, String productId,
String currentCategoryId, String previousCategoryId) {
if (UtilValidate.isNotEmpty(productId)) {
return makeProductUrl(delegator, dispatcher, locale, crumb, targetWebappInfo, currentCatalogId, previousCategoryId, currentCategoryId, productId);
} else {
return makeCategoryUrl(delegator, dispatcher, locale, crumb, targetWebappInfo, currentCatalogId, previousCategoryId, currentCategoryId, productId, null, null, null, null);
}
//return CatalogUrlBuilder.getDefaultBuilder().makeCatalogUrl(delegator, dispatcher, locale, contextPath, crumb, productId, currentCategoryId, previousCategoryId);
}
}
public CatalogAltUrlBuilder getCatalogAltUrlBuilder() {
return catalogAltUrlBuilder;
}
public class SeoCatalogAltUrlBuilder extends CatalogAltUrlBuilder implements Serializable {
@Override
public String makeProductAltUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId,
String productId) {
return makeProductUrl(request, locale, previousCategoryId, productCategoryId, productId);
//return CatalogAltUrlBuilder.getDefaultBuilder().makeProductAltUrl(request, locale, 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) {
return makeProductUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId);
//return CatalogAltUrlBuilder.getDefaultBuilder().makeProductAltUrl(delegator, dispatcher, locale, trail, contextPath, 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 makeCategoryUrl(request, locale, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
//return CatalogAltUrlBuilder.getDefaultBuilder().makeCategoryAltUrl(request, locale, 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) {
return makeCategoryUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
//return CatalogAltUrlBuilder.getDefaultBuilder().makeCategoryAltUrl(delegator, dispatcher, locale, trail, contextPath, previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
}
}
/*
* *****************************************************
* URL sanitizing
* *****************************************************
* These control how much happens before & after storage.
*/
public CatalogAltUrlSanitizer getCatalogAltUrlSanitizer() {
return catalogAltUrlSanitizer;
}
public class SeoCatalogAltUrlSanitizer extends CatalogAltUrlSanitizer {
@Override
public String convertNameToDbAltUrl(String name, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
name = processNameToAltUrl(name, locale, entityType, ctxInfo);
name = normalizeAltUrl(name, locale, entityType, ctxInfo);
return name;
}
@Override
public String convertIdToDbAltUrl(String id, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
// TODO: REVIEW: leaving this same as live for now... doubtful...
return convertIdToLiveAltUrl(id, locale, entityType, ctxInfo);
}
@Override
public String sanitizeAltUrlFromDb(String altUrl, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
// WARN: due to content wrapper the locale might not be the one from the pathSegment!!
// may also be null
if (altUrl == null) return "";
// 2017: LEAVE THIS METHOD EMPTY - allows better DB queries if no post-processing.
// TODO: REVIEW: REMOVED all post-db processing for now
// - omitting could permit better DB queries
// the reason this existed in the first place was because users can input garbage
// through the UI, could could prevent in other ways...
//pathSegment = UrlServletHelper.invalidCharacter(pathSegment); // (stock ofbiz)
return altUrl;
}
@Override
public String convertIdToLiveAltUrl(String id, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
// TODO: REVIEW: this is what the old Seo code did, but it will just not work in the filters...
// People should not generate DB IDs with spaces
//return id.trim().replaceAll(" ", SeoStringUtil.URL_HYPHEN);
return id;
}
protected String processNameToAltUrl(String name, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
return getConfig().getAltUrlGenProcessors().processUrl(name);
}
protected String normalizeAltUrl(String name, Locale locale, CatalogUrlType entityType, SanitizeContext ctxInfo) {
if (entityType == CatalogUrlType.PRODUCT) {
name = truncateAltUrl(name, getConfig().getProductNameMaxLength());
} else if (entityType == CatalogUrlType.CATEGORY) {
name = truncateAltUrl(name, getConfig().getCategoryNameMaxLength());
}
return trimAltUrl(name);
}
protected String truncateAltUrl(String name, Integer maxLength) {
if (name == null || maxLength == null || maxLength < 0 || name.length() <= maxLength) {
return name;
}
return name.substring(0, maxLength);
}
protected String trimAltUrl(String name) {
return SeoStringUtil.trimSeoName(name);
}
}
/*
* *****************************************************
* URL building core
* *****************************************************
* Derived from CatalogUrlFilter methods of same names.
* NOTE: The alt and non-alt SEO methods are unified to produce same output.
*/
/**
* Convert list of categoryIds to formatted alt url names, EXCLUDING the passed targetCategory (skips last elem if matches).
* WARNING: this method may change without notice.
* TODO?: refactor?: tries to cover too many cases
*/
protected List<String> getCategoryTrailPathSegments(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<GenericValue> trailEntities, GenericValue targetCategory,
SeoConfig.TrailFormat trailFormat, SeoConfig.TrailFormat targetCategoryFormat, CatalogUrlType urlType, CatalogAltUrlSanitizer.SanitizeContext targetSanitizeCtx, boolean useCache) {
if (trailEntities == null || trailEntities.isEmpty()) return newPathList();
List<String> catNames = newPathList(trailEntities.size());
ListIterator<GenericValue> trailIt = trailEntities.listIterator();
CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(targetSanitizeCtx);
// TODO: REVIEW: Just copy it and override for now; previously there was no copy-constructor
//CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(delegator, dispatcher, locale, useCache);
//if (targetSanitizeCtx != null) {
// sanitizeCtx.setTargetProduct(targetSanitizeCtx.getTargetProduct());
// sanitizeCtx.setTargetCategory(targetSanitizeCtx.getTargetCategory());
// sanitizeCtx.setTotalNames(targetSanitizeCtx.getTotalNames());
//}
sanitizeCtx.setNameIndex(0);
sanitizeCtx.setLast(false);
String targetCategoryId = (targetCategory != null) ? targetCategory.getString("productCategoryId") : null;
while(trailIt.hasNext()) {
GenericValue productCategory = trailIt.next();
if (productCategory == null) {
continue;
}
String productCategoryId = productCategory.getString("productCategoryId");
if ("TOP".equals(productCategoryId)) continue;
if (targetCategoryId != null && !trailIt.hasNext() && targetCategoryId.equals(productCategoryId)) {
break; // skip last if it's the target
}
String trailSegment = getCategoryPathSegment(delegator, dispatcher, locale, productCategory, trailFormat, sanitizeCtx, useCache);
if (trailSegment != null) catNames.add(trailSegment);
sanitizeCtx.increaseNameIndex();
}
return catNames;
}
/**
* Reads ALTERNATIVE_URL for category and locale from DB and builds config-specified alt url path part, or according to the passed format.
* Fallback on ID.
*/
public String getCategoryPathSegment(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue productCategory, SeoConfig.TrailFormat format, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
String name = getCategoryPathName(delegator, dispatcher, locale, productCategory, format, sanitizeCtx, useCache);
if (UtilValidate.isNotEmpty(name)) {
if (!sanitizeCtx.isLast()) {
if (getConfig().isCategoryNameAppendId()) {
name += SeoStringUtil.URL_HYPHEN + getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(productCategory.getString("productCategoryId"), locale, CatalogUrlType.CATEGORY, sanitizeCtx);
}
} else {
if (getConfig().isCategoryNameAppendIdLast()) {
name += SeoStringUtil.URL_HYPHEN + getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(productCategory.getString("productCategoryId"), locale, CatalogUrlType.CATEGORY, sanitizeCtx);
}
}
return name;
} else { // fallback
return getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(productCategory.getString("productCategoryId"), locale, CatalogUrlType.CATEGORY, sanitizeCtx);
}
}
protected String getCategoryPathName(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue productCategory, SeoConfig.TrailFormat format, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
if (format == SeoConfig.TrailFormat.ID) {
return getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(productCategory.getString("productCategoryId"), locale, CatalogUrlType.CATEGORY, sanitizeCtx);
}
return getCategoryAltUrl(delegator, dispatcher, locale, productCategory, sanitizeCtx, useCache);
}
protected String getCategoryAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue productCategory, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
String altUrl = CategoryContentWrapper.getProductCategoryContentAsText(productCategory, "ALTERNATIVE_URL", locale, dispatcher, useCache, "raw");
if (UtilValidate.isNotEmpty(altUrl)) {
// FIXME: effective locale might not be same as "locale" variable!
altUrl = getCatalogAltUrlSanitizer().sanitizeAltUrlFromDb(altUrl, locale, CatalogUrlType.CATEGORY, sanitizeCtx);
}
return altUrl;
}
protected List<GenericValue> getCategoriesFromIdList(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> categoryIdList, boolean useCache) {
List<GenericValue> categoryList = new ArrayList<>(categoryIdList.size());
for(String productCategoryId : categoryIdList) {
try {
GenericValue productCategory = EntityQuery.use(delegator).from("ProductCategory")
.where("productCategoryId", productCategoryId).cache(useCache).queryOne();
categoryList.add(productCategory);
} catch(Exception e) {
Debug.logError(e, "Seo: Cannot get category '" + productCategoryId + "' alt url", module);
}
}
return categoryList;
}
protected List<String> findBestTopCatTrailForNewUrl(Delegator delegator, List<List<String>> trails, List<String> hintTrail, Collection<String> topCategoryIds) {
if (trails.size() == 0) {
return newPathList();
} else if (trails.size() == 1) {
// if only one trail, we'll assume it leads to top category
return trails.get(0);
} else {
ClosestTrailResolver.ResolverType resolverType = getConfig().getNewUrlTrailResolver();
return ensurePathList(resolverType.getResolver().findClosestTrail(trails, hintTrail, topCategoryIds));
}
}
public String makeCategoryUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString) {
Delegator delegator = (Delegator) request.getAttribute("delegator");
LocalDispatcher dispatcher = (LocalDispatcher) request.getAttribute("dispatcher");
List<String> trail = CategoryWorker.getTrail(request);
FullWebappInfo targetWebappInfo = FullWebappInfo.fromRequest(request);
String currentCatalogId = CatalogWorker.getCurrentCatalogId(request);
return makeCategoryUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId,
previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString);
}
/**
* Make category url according to the configurations.
*/
public String makeCategoryUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> currentTrail, FullWebappInfo targetWebappInfo,
String currentCatalogId, String previousCategoryId, String productCategoryId, String productId, String viewSize, String viewIndex, String viewSort, String searchString) {
final boolean useCache = true; // NOTE: this is for entity cache lookups, not util caches
List<String> trail;
if (getConfig().getCategoryUrlTrailFormat().isOn()) {
trail = mapCategoryUrlTrail(delegator, currentTrail, productCategoryId, targetWebappInfo.getWebSiteId(), currentCatalogId);
} else {
trail = Collections.emptyList();
}
String key = getCategoryCacheKey(delegator, targetWebappInfo, "Default", locale, previousCategoryId, productCategoryId, productId,
viewSize, viewIndex, viewSort, searchString, currentCatalogId, trail);
String url = categoryUrlCache.get(key);
if (url == null) {
url = makeCategoryUrlImpl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId,
previousCategoryId, productCategoryId, productId, viewSize, viewIndex, viewSort, searchString, useCache);
categoryUrlCache.put(key, url);
if (DEBUG) {
Debug.logInfo("Seo: makeCategoryUrl: Created category url [" + url + "] for key [" + key + "]", module);
}
} else {
if (DEBUG) {
Debug.logInfo("Seo: makeCategoryUrl: Got cached category url [" + url + "] for key [" + key + "]", module);
}
}
return url;
}
/**
* NOTE: caching the trail effectively renders the entries cached per-category-page, but there is no way around with without adding more complexity.
*/
protected static String getCategoryCacheKey(Delegator delegator, FullWebappInfo targetWebappInfo, String type, Locale locale, String previousCategoryId, String productCategoryId, String productId,
String viewSize, String viewIndex, String viewSort, String searchString, String currentCatalogId, List<String> trail) {
String localeStr;
if (locale == null) {
localeStr = UtilProperties.getPropertyValue("scipiosetup", "store.defaultLocaleString");
if (UtilValidate.isEmpty(localeStr)) {
localeStr = Locale.getDefault().toString();
}
} else {
localeStr = locale.toString();
}
return delegator.getDelegatorName()+"::"+type+"::"+targetWebappInfo.getContextPath()+"::"+targetWebappInfo.getWebSiteId()+"::"+localeStr+"::"+previousCategoryId+"::"+productCategoryId+"::"
+productId+"::"+viewSize+"::"+viewSort+"::"+searchString+"::"+currentCatalogId+"::"+String.join("/", trail);
}
/**
* Creates a full trail to the given category using hints from the incoming trail to select best.
* Caching.
*/
protected List<String> mapCategoryUrlTrail(Delegator delegator, List<String> hintTrail, String productCategoryId, String webSiteId, String currentCatalogId) {
List<String> trail = null;
String trailKey = delegator.getDelegatorName() + "::" + webSiteId + "::" + productCategoryId + "::" + currentCatalogId;
TrailCacheEntry trailEntry = categoryTrailCache.get(trailKey);
if (trailEntry == null) {
Set<String> topCategoryIds = getCatalogTopCategoriesForCategoryUrl(delegator, currentCatalogId, webSiteId);
List<List<String>> trails = null;
if (topCategoryIds.isEmpty()) {
Debug.logWarning("Seo: mapCategoryUrlTrail: No top categories found for catalog '" + currentCatalogId + "'; can't select best trail", module);
topCategoryIds = null;
} else {
trails = getCategoryRollupTrails(delegator, productCategoryId, topCategoryIds);
}
trailEntry = new TrailCacheEntry(topCategoryIds, trails);
categoryTrailCache.put(trailKey, trailEntry);
}
if (trailEntry.getTopCategoryIds() != null) {
trail = findBestTopCatTrailForNewUrl(delegator, trailEntry.getTrails(), hintTrail, trailEntry.getTopCategoryIds()); // fast
}
return (trail != null) ? trail : Collections.emptyList();
}
protected String makeCategoryUrlImpl(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, boolean useCache) {
GenericValue productCategory;
try {
productCategory = EntityQuery.use(delegator).from("ProductCategory").where("productCategoryId", productCategoryId).cache().queryOne();
if (productCategory == null) {
Debug.logWarning("Seo: Category not found: Cannot create category's URL for: " + productCategoryId, module);
return null;
}
} catch (GenericEntityException e) {
Debug.logWarning(e, "Seo: Cannot create category's URL for: " + productCategoryId, module);
return null;
}
// NO LONGER NEED ADJUST - in fact it will prevent the valid trail selection after this from working
//trail = CategoryWorker.adjustTrail(trail, productCategoryId, previousCategoryId);
StringBuilder urlBuilder = makeCategoryUrlCore(delegator, dispatcher, locale, productCategory, currentCatalogId, previousCategoryId,
getCategoriesFromIdList(delegator, dispatcher, locale, trail, useCache), targetWebappInfo, useCache);
appendCategoryUrlParam(urlBuilder, "viewIndex", viewIndex);
appendCategoryUrlParam(urlBuilder, "viewSize", viewSize);
appendCategoryUrlParam(urlBuilder, "viewSort", viewSort);
appendCategoryUrlParam(urlBuilder, "searchString", searchString);
if (urlBuilder.toString().endsWith("&")) {
return urlBuilder.toString().substring(0, urlBuilder.toString().length()-1);
}
return urlBuilder.toString();
}
private void appendCategoryUrlParam(StringBuilder urlBuilder, String paramName, String paramValue) {
if (UtilValidate.isNotEmpty(paramValue)) {
if (!urlBuilder.toString().endsWith("?") && !urlBuilder.toString().endsWith("&")) {
urlBuilder.append("?");
}
urlBuilder.append(paramName);
urlBuilder.append("=");
urlBuilder.append(paramValue);
urlBuilder.append("&");
}
}
/**
* Builds full category URL from trail of category IDs and the given category, according to configuration.
*/
public StringBuilder makeCategoryUrlCore(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue productCategory,
String currentCatalogId, String previousCategoryId, List<GenericValue> trailEntities, FullWebappInfo targetWebappInfo, boolean useCache) {
String productCategoryId = productCategory.getString("productCategoryId");
if (trailEntities == null) {
trailEntities = Collections.emptyList();
} else if (!getConfig().getCategoryUrlTrailFormat().isOn() && trailEntities.size() > 1) {
GenericValue lastElem = trailEntities.get(trailEntities.size() - 1);
trailEntities = new ArrayList<>(1);
trailEntities.add(lastElem);
}
CatalogAltUrlSanitizer.SanitizeContext targetSanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(delegator, dispatcher, locale, useCache)
.setTargetCategory(productCategory).setLast(true).setNameIndex(trailEntities.size() - 1).setTotalNames(trailEntities.size());
List<String> trailNames = getCategoryTrailPathSegments(delegator, dispatcher, locale, trailEntities,
productCategory, getConfig().getCategoryUrlTrailFormat(), SeoConfig.TrailFormat.NAME, CatalogUrlType.CATEGORY, targetSanitizeCtx, useCache);
trailNames = getCatalogAltUrlSanitizer().adjustCategoryLiveAltUrlTrail(trailNames, locale, targetSanitizeCtx);
// NOTE: pass null productCategory because already resolved in trailNames
return makeCategoryUrlPath(delegator, dispatcher, locale, productCategory, trailNames, targetWebappInfo.getContextPath(), targetSanitizeCtx, useCache);
}
/**
* Builds the core category URL path.
* NOTE: productCategory may be null, in which case assume already resolved as part of trailNames.
* Assumes trailNames already valid.
*/
public StringBuilder makeCategoryUrlPath(Delegator delegator, LocalDispatcher dispatcher, Locale locale,
GenericValue productCategory, List<String> trailNames, String contextPath, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
StringBuilder urlBuilder = new StringBuilder();
if (contextPath != null) {
urlBuilder.append(contextPath);
}
SeoConfig config = getConfig();
// DEV NOTE: I removed getConfig().isHandleImplicitRequests() check from here because was inconsistent and not really needed or wanted
boolean explicitCategoryRequest = !config.isGenerateImplicitCategoryUrl();
if (explicitCategoryRequest && !config.isGenerateCategoryAltUrlSuffix()) {
appendSlashAndValue(urlBuilder, getCategoryServletPathName(locale));
}
appendSlashAndValue(urlBuilder, trailNames);
if (productCategory != null) {
if (sanitizeCtx == null) {
sanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(delegator, dispatcher, locale, useCache).setTargetCategory(productCategory)
.setLast(true).setNameIndex(trailNames.size()).setTotalNames(trailNames.size() + 1);
}
String catTrailName = getCategoryPathSegment(delegator, dispatcher, locale, productCategory, null, sanitizeCtx, useCache);
if (catTrailName != null) {
appendSlashAndValue(urlBuilder, catTrailName);
}
}
// legacy category alt url suffix ("-c")
if (explicitCategoryRequest && config.isGenerateCategoryAltUrlSuffix()) {
urlBuilder.append(config.getCategoryAltUrlSuffix());
}
// general URL suffix/extension (".html")
checkAddUrlSuffix(urlBuilder);
return getCatalogAltUrlSanitizer().adjustCategoryLiveAltUrlPath(urlBuilder, locale, sanitizeCtx);
}
public StringBuilder makeCategoryUrlPath(Delegator delegator, LocalDispatcher dispatcher, Locale locale,
GenericValue productCategory, List<String> trailNames, String contextPath, boolean useCache) {
return makeCategoryUrlPath(delegator, dispatcher, locale, productCategory, trailNames, contextPath, null, useCache);
}
public String makeProductUrl(HttpServletRequest request, Locale locale, String previousCategoryId, String productCategoryId, String productId) {
Delegator delegator = (Delegator) request.getAttribute("delegator");
LocalDispatcher dispatcher = (LocalDispatcher) request.getAttribute("dispatcher");
List<String> trail = CategoryWorker.getTrail(request);
FullWebappInfo targetWebappInfo = FullWebappInfo.fromRequest(request);
String currentCatalogId = CatalogWorker.getCurrentCatalogId(request);
return makeProductUrl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId);
}
/**
* Make product url according to the configurations.
* <p>
* SCIPIO: Modified for bugfixes and lookup via cache products map.
*/
public String makeProductUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> currentTrail, FullWebappInfo targetWebappInfo,
String currentCatalogId, String previousCategoryId, String productCategoryId, String productId) {
final boolean useCache = true; // NOTE: this is for entity cache lookups, not util caches
List<String> trail;
if (!getConfig().isCategoryNameEnabled() && !getConfig().getProductUrlTrailFormat().isOn()) {
trail = Collections.emptyList(); // no need for trail
} else {
// NO LONGER NEED ADJUST (stock ofbiz logic) - in fact it will prevent the valid trail selection after this from working
//if (UtilValidate.isNotEmpty(productCategoryId)) {
// currentTrail = CategoryWorker.adjustTrail(currentTrail, productCategoryId, previousCategoryId);
//}
trail = mapProductUrlTrail(delegator, currentTrail, productId, targetWebappInfo.getWebSiteId(), currentCatalogId);
}
String key = getProductUrlCacheKey(delegator, targetWebappInfo, "Default", locale, previousCategoryId, productCategoryId,
productId, currentCatalogId, trail);
String url = productUrlCache.get(key);
if (url == null) {
url = makeProductUrlImpl(delegator, dispatcher, locale, trail, targetWebappInfo, currentCatalogId, previousCategoryId, productCategoryId, productId, useCache);
productUrlCache.put(key, url);
if (DEBUG) {
Debug.logInfo("makeProductUrl: Created product url [" + url + "] for key [" + key + "]", module);
}
} else {
if (DEBUG) {
Debug.logInfo("makeProductUrl: Got cached product url [" + url + "] for key [" + key + "]", module);
}
}
return url;
}
protected static String getProductUrlCacheKey(Delegator delegator, FullWebappInfo targetWebappInfo, String type, Locale locale, String previousCategoryId, String productCategoryId, String productId, String currentCatalogId, List<String> trail) {
String localeStr;
if (locale == null) {
localeStr = UtilProperties.getPropertyValue("scipiosetup", "store.defaultLocaleString");
if (UtilValidate.isEmpty(localeStr)) {
localeStr = Locale.getDefault().toString();
}
} else {
localeStr = locale.toString();
}
return delegator.getDelegatorName()+"::"+type+"::"+targetWebappInfo.getContextPath()+"::"+targetWebappInfo.getWebSiteId()+"::"+localeStr+"::"+previousCategoryId+"::"+productCategoryId+"::"
+productId+"::"+currentCatalogId+"::"+String.join("/", trail);
}
/**
* Creates a full trail to the given product using hints from the incoming trail to select best.
*/
protected List<String> mapProductUrlTrail(Delegator delegator, List<String> hintTrail, String productId, String webSiteId, String currentCatalogId) {
List<String> trail = null;
String trailKey = delegator.getDelegatorName() + "::" + webSiteId + "::" + productId + "::" + currentCatalogId;
TrailCacheEntry trailEntry = productTrailCache.get(trailKey);
if (trailEntry == null) {
GenericValue product = null;
try {
product = EntityQuery.use(delegator).from("Product").where("productId", productId).cache().queryOne();
} catch (GenericEntityException e) {
Debug.logWarning(e, "Seo: Cannot create product's URL for: " + productId, module);
}
Set<String> topCategoryIds = null;
List<List<String>> trails = null;
if (product != null) {
topCategoryIds = getCatalogTopCategoriesForProductUrl(delegator, currentCatalogId, webSiteId);
if (topCategoryIds.isEmpty()) {
Debug.logWarning("Seo: mapProductUrlTrail: No top category found for catalog '" + currentCatalogId + "'; can't select best trail", module);
topCategoryIds = null;
} else {
try {
String primaryCatId = product.getString("primaryProductCategoryId");
if (primaryCatId != null) { // prioritize primary product category
trails = getCategoryRollupTrails(delegator, primaryCatId, topCategoryIds);
} else { // no primary, use rollups
List<GenericValue> prodCatMembers = EntityQuery.use(delegator).from("ProductCategoryMember")
.where("productId", productId).orderBy("-fromDate").filterByDate().cache().queryList();
if (prodCatMembers.size() > 0) {
//trails = null;
for (GenericValue prodCatMember : prodCatMembers) {
String productCategoryId = prodCatMember.getString("productCategoryId");
List<List<String>> memberTrails = getCategoryRollupTrails(delegator, productCategoryId, topCategoryIds);
if (trails == null) trails = memberTrails;
else trails.addAll(memberTrails);
}
}
}
} catch (Exception e) {
Debug.logError(e, "Seo: Error generating trail for product '" + productId + "': " + e.getMessage(), module);
}
}
}
trailEntry = new TrailCacheEntry(topCategoryIds, trails);
productTrailCache.put(trailKey, trailEntry);
}
if (trailEntry.getTopCategoryIds() != null) {
trail = findBestTopCatTrailForNewUrl(delegator, trailEntry.getTrails(), hintTrail, trailEntry.getTopCategoryIds()); // fast
}
return (trail != null) ? trail : Collections.emptyList();
}
/**
* Make product url according to the configurations.
* <p>
* SCIPIO: Modified for bugfixes and lookup via cache products map.
*/
protected String makeProductUrlImpl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, List<String> trail, FullWebappInfo targetWebappInfo,
String currentCatalogId, String previousCategoryId, String productCategoryId, String productId, boolean useCache) {
GenericValue product;
try {
product = EntityQuery.use(delegator).from("Product").where("productId", productId).cache().queryOne();
} catch (GenericEntityException e) {
Debug.logWarning(e, "Seo: Cannot create product's URL for: " + productId, module);
return null;
}
return makeProductUrlCore(delegator, dispatcher, locale, product, currentCatalogId, previousCategoryId,
getCategoriesFromIdList(delegator, dispatcher, locale, trail, useCache), targetWebappInfo, useCache).toString();
}
/**
* Builds full product URL from trail of category IDs and the given product, according to configuration.
*/
public StringBuilder makeProductUrlCore(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product,
String currentCatalogId, String previousCategoryId, List<GenericValue> trailEntities, FullWebappInfo targetWebappInfo, boolean useCache) {
CatalogAltUrlSanitizer.SanitizeContext targetSanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(delegator, dispatcher, locale, useCache)
.setLast(true).setNameIndex(trailEntities.size()).setTotalNames(trailEntities.size() + 1);
List<String> trailNames = Collections.emptyList();
if (UtilValidate.isNotEmpty(trailEntities) && getConfig().isCategoryNameEnabled() && getConfig().getProductUrlTrailFormat().isOn()) {
trailNames = getCategoryTrailPathSegments(delegator, dispatcher, locale, trailEntities,
null, getConfig().getProductUrlTrailFormat(), null, CatalogUrlType.PRODUCT, targetSanitizeCtx, useCache);
}
trailNames = getCatalogAltUrlSanitizer().adjustProductLiveAltUrlTrail(trailNames, locale, targetSanitizeCtx);
return makeProductUrlPath(delegator, dispatcher, locale, product, trailNames, targetWebappInfo.getContextPath(), targetSanitizeCtx, useCache);
}
/**
* Builds the core product URL path.
* NOTE: product may be null, in which case assume already resolved as part of trailNames.
* Assumes trailNames already valid.
*/
public StringBuilder makeProductUrlPath(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product, List<String> trailNames, String contextPath, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
StringBuilder urlBuilder = new StringBuilder();
if (contextPath != null) {
urlBuilder.append(contextPath);
}
SeoConfig config = getConfig();
int catCount = trailNames.size();
if (product == null && catCount > 0) catCount--; // product already in trail
boolean explicitProductRequest;
// DEV NOTE: I removed config.isHandleImplicitRequests() check from here because was inconsistent and not really needed or wanted
if (catCount > 0) {
explicitProductRequest = !config.isGenerateImplicitProductUrl();
} else {
// NOTE: in old code, seo only support(ed) implicit request if there was at least one category
explicitProductRequest = !config.isGenerateImplicitProductUrlNoCat();
}
if (explicitProductRequest && !config.isGenerateProductAltUrlSuffix()) {
appendSlashAndValue(urlBuilder, getProductServletPathName(locale));
}
// append category names
if (config.isCategoryNameEnabled() && config.getProductUrlTrailFormat().isOn()) {
appendSlashAndValue(urlBuilder, trailNames);
}
if (product != null) {
// 2017-11-08: NOT SUPPORTED: could only theoretically work if chose different character than hyphen
//if (!trailNames.isEmpty() && !SeoConfigUtil.isCategoryNameSeparatePathElem()) {
// urlBuilder.append(SeoStringUtil.URL_HYPHEN);
//} else {
ensureTrailingSlash(urlBuilder);
//}
if (sanitizeCtx == null) {
sanitizeCtx = getCatalogAltUrlSanitizer().makeSanitizeContext(delegator, dispatcher, locale, useCache).setTargetProduct(product);
}
urlBuilder.append(getProductPathSegment(delegator, dispatcher, locale, product, sanitizeCtx, useCache));
}
// legacy product alt url suffix ("-p")
if (explicitProductRequest && config.isGenerateProductAltUrlSuffix()) {
urlBuilder.append(config.getProductAltUrlSuffix());
}
// general URL suffix/extension (".html")
checkAddUrlSuffix(urlBuilder);
return getCatalogAltUrlSanitizer().adjustProductLiveAltUrlPath(urlBuilder, locale, sanitizeCtx);
}
public StringBuilder makeProductUrlPath(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product, List<String> trailNames, String contextPath, boolean useCache) {
return makeProductUrlPath(delegator, dispatcher, locale, product, trailNames, contextPath, null, useCache);
}
protected String getProductPathSegment(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
String name = getProductPathName(delegator, dispatcher, locale, product, sanitizeCtx, useCache);
if (UtilValidate.isNotEmpty(name)) {
if (config.isProductNameAppendId()) {
return getConfig().getProductUrlTargetPattern().expandString(UtilMisc.toMap("name", name, "id",
getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(product.getString("productId"), locale, CatalogUrlType.PRODUCT, sanitizeCtx)));
} else {
return name;
}
} else { // fallback
return getCatalogAltUrlSanitizer().convertIdToLiveAltUrl(product.getString("productId"), locale, CatalogUrlType.PRODUCT, sanitizeCtx);
}
}
protected String getProductPathName(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
return getProductAltUrl(delegator, dispatcher, locale, product, sanitizeCtx, useCache);
}
protected String getProductAltUrl(Delegator delegator, LocalDispatcher dispatcher, Locale locale, GenericValue product, CatalogAltUrlSanitizer.SanitizeContext sanitizeCtx, boolean useCache) {
String altUrl = ProductContentWrapper.getProductContentAsText(product, "ALTERNATIVE_URL", locale, dispatcher, useCache, "raw");
if (UtilValidate.isNotEmpty(altUrl)) {
// FIXME: effective locale might not be same as "locale" variable!
return getCatalogAltUrlSanitizer().sanitizeAltUrlFromDb(altUrl, locale, CatalogUrlType.PRODUCT, sanitizeCtx);
}
return null;
}
protected void checkAddUrlSuffix(StringBuilder sb) {
String urlSuffix = getUrlSuffix();
if (UtilValidate.isNotEmpty(urlSuffix) && (sb.length() > 0) && (sb.charAt(sb.length() - 1) != '/')) {
sb.append(urlSuffix);
}
}
/*
* *****************************************************
* URL matching & parsing
* *****************************************************
*/
public PathMatch makePathMatchIfValidRequest(Delegator delegator, PathEntity pathEntity, String path, List<String> pathSegments,
String contextPath, String webSiteId, String currentCatalogId,
boolean explicitProductRequest, boolean explicitCategoryRequest, Locale explicitLocale, Timestamp moment) {
if (explicitProductRequest || explicitCategoryRequest || pathEntity != null) {
return makePathMatch(delegator, pathEntity, path, pathSegments, contextPath, webSiteId, currentCatalogId, explicitProductRequest, explicitCategoryRequest, explicitLocale, moment);
}
return null;
}
public PathMatch makePathMatch(Delegator delegator, PathEntity pathEntity, String path, List<String> pathSegments,
String contextPath, String webSiteId, String currentCatalogId,
boolean explicitProductRequest, boolean explicitCategoryRequest, Locale explicitLocale, Timestamp moment) {
return new PathMatch(pathEntity, path, pathSegments, contextPath, webSiteId, currentCatalogId, explicitProductRequest, explicitCategoryRequest, explicitLocale, moment);
}
/**
* Returned whenever we find a URL that appears to be an SEO URL, even if the request is not for a valid product or category.
*
* <p>SCIPIO: 3.0.0: Also becomes a facade around {@link PathEntity}.</p>
*/
public static class PathMatch implements Serializable {
protected PathEntity pathEntity;
protected String path;
protected List<String> pathSegments; // path segments (after mount-point, if any)
protected Locale explicitLocale;
protected boolean explicitProductRequest;
protected boolean explicitCategoryRequest;
protected Timestamp moment;
public PathMatch() {
}
public PathMatch(PathEntity pathEntity, String path, List<String> pathSegments, String contextPath, String webSiteId,
String currentCatalogId, boolean explicitProductRequest, boolean explicitCategoryRequest, Locale explicitLocale, Timestamp moment) {
this.pathEntity = pathEntity;
this.path = path;
this.pathSegments = ensurePathList(pathSegments);
this.explicitProductRequest = explicitProductRequest;
this.explicitCategoryRequest = explicitCategoryRequest;
this.explicitLocale = explicitLocale;
this.moment = moment;
}
public PathMatch(PathMatch other) {
this.pathEntity = other.pathEntity;
this.path = other.path;
this.pathSegments = other.pathSegments;
this.explicitProductRequest = other.explicitProductRequest;
this.explicitCategoryRequest = other.explicitCategoryRequest;
this.explicitLocale = other.explicitLocale;
this.moment = other.moment;
}
/**
* Returns the main match entity and trail (aliased using other calls below for convenience).
*/
public PathEntity getPathEntity() {
return pathEntity;
}
/** Returns the original path that was matched. **/
public String getPath() { return path; }
/** Returns the original path elements, excluding any explicit /product or /category mount-point. **/
public List<String> getPathSegments() { return pathSegments; }
/** Returns the target match, or null if no productId or categoryId could be matched. **/
public PathSegmentEntity getTargetEntity() {
PathEntity pathEntity = getPathEntity();
return (pathEntity != null) ? pathEntity.getTargetEntity() : null;
}
public CatalogUrlType getTargetEntityType() {
PathEntity pathEntity = getPathEntity();
return (pathEntity != null) ? pathEntity.getTargetEntityType() : null;
}
public String getTargetProductId() { return hasTargetProduct() ? getTargetEntity().getId() : null; }
public String getTargetCategoryId() { return hasTargetCategory() ? getTargetEntity().getId() : null; }
/**
* Excludes target category (and product).
*/
public List<String> getTrailCategoryIds() {
PathEntity pathEntity = getPathEntity();
return (pathEntity != null) ? pathEntity.getTrailCategoryIds() : null;
}
/**
* Includes target category (but not product).
*/
public List<String> getFullTrailCategoryIds() {
PathEntity pathEntity = getPathEntity();
return (pathEntity != null) ? pathEntity.getFullTrailCategoryIds() : null;
}
public String getParentCategoryId() {
List<String> trailCategoryIds = getTrailCategoryIds();
return UtilValidate.isNotEmpty(trailCategoryIds) ? trailCategoryIds.get(trailCategoryIds.size() - 1) : null;
}
public boolean hasTargetProduct() {
return (getTargetEntityType() == CatalogUrlType.PRODUCT);
}
public boolean hasTargetCategory() {
return (getTargetEntityType() == CatalogUrlType.CATEGORY);
}
public boolean isExplicitProductRequest() {
return explicitProductRequest;
}
public boolean isExplicitCategoryRequest() {
return explicitCategoryRequest;
}
public boolean isProductRequest() {
return isExplicitProductRequest() || hasTargetProduct();
}
public boolean isCategoryRequest() {
return isExplicitCategoryRequest() || hasTargetCategory();
}
/**
* The locale that the explicit product/category path prefix matched (most reliable) - may be null for implicit requests.
*
* <p>If using implicit requests, locale will be implied by the target entity match.</p>
*/
public Locale getExplicitLocale() {
return explicitLocale;
}
public String getExplicitLocaleString() {
Locale explicitLocale = getExplicitLocale();
return (explicitLocale != null) ? explicitLocale.toString() : null;
}
public Timestamp getMoment() {
return moment;
}
public PathMatch setPathEntity(PathEntity pathEntity) {
this.pathEntity = pathEntity;
return this;
}
public PathMatch setPath(String path) {
this.path = path;
return this;
}
public PathMatch setPathSegments(List<String> pathSegments) {
this.pathSegments = pathSegments;
return this;
}
public PathMatch setExplicitProductRequest(boolean explicitProductRequest) {
this.explicitProductRequest = explicitProductRequest;
return this;
}
public PathMatch setExplicitCategoryRequest(boolean explicitCategoryRequest) {
this.explicitCategoryRequest = explicitCategoryRequest;
return this;
}
public PathMatch setExplicitLocale(Locale explicitLocale) {
this.explicitLocale = explicitLocale;
return this;
}
public PathMatch setMoment(Timestamp moment) {
this.moment = moment;
return this;
}
}
/**
* Checks if the path (starting after context path) appears to be an SEO
* URL and returns its info if so.
*
* @param path path starting from context path, in other words servlet path + path info
*/
public PathMatch matchInboundSeoCatalogUrl(Delegator delegator, String path, String contextPath, String webSiteId, String currentCatalogId) {
// clean up the path
String pathInfo = preprocessInboundSeoCatalogUrlPath(path);
if (pathInfo == null) {
return null;
}
// check/strip the URL suffix
// 2020-01-24: This used to be done in preprocessInboundSeoCatalogUrlPath, but the trailing slash
// has to be stripped BEFORE the suffix stripped (it makes no sense for suffix to be separated by a slash)
// and we need to handle when the suffix is configured but missing
String urlSuffix = getUrlSuffix();
boolean urlSuffixFail = false;
if (UtilValidate.isNotEmpty(urlSuffix)) {
if (pathInfo.endsWith(urlSuffix)) {
pathInfo = pathInfo.substring(0, pathInfo.length() - urlSuffix.length());
} else if (config.isSeoUrlSuffixMatchRequired()) {
urlSuffixFail = true;
}
}
// split path into alt-url parts
List<String> pathSegments = StringUtil.split(pathInfo, "/");
if (UtilValidate.isEmpty(pathSegments)) {
return null;
}
boolean explicitCategoryRequest = false;
boolean explicitProductRequest = false;
// the locale the URL appears to be: the path prefix if explicit, or the cat/prod name language if implicit
Locale explicitLocale = null;
String lastPathSegment = pathSegments.get(pathSegments.size() - 1);
SeoConfig config = getConfig();
// determine the general form of the URL: explicit or implicit (+ locale at same time)
explicitLocale = getProductServletPathNameLocale(pathSegments.get(0));
if (explicitLocale != null) {
explicitProductRequest = true;
pathSegments.remove(0);
} else {
explicitLocale = getCategoryServletPathNameLocale(pathSegments.get(0));
if (explicitLocale != null) {
explicitCategoryRequest = true;
pathSegments.remove(0);
} else {
// LEGACY suffix support for backward compat with published links
if (config.isHandleProductAltUrlSuffix() && lastPathSegment.endsWith(config.getProductAltUrlSuffix())) {
explicitProductRequest = true;
lastPathSegment = lastPathSegment.substring(0, lastPathSegment.length() - config.getProductAltUrlSuffix().length());
pathSegments.set(pathSegments.size() - 1, lastPathSegment);
} else if (config.isHandleCategoryAltUrlSuffix() && lastPathSegment.endsWith(config.getCategoryAltUrlSuffix())) {
explicitCategoryRequest = true;
lastPathSegment = lastPathSegment.substring(0, lastPathSegment.length() - config.getProductAltUrlSuffix().length());
pathSegments.set(pathSegments.size() - 1, lastPathSegment);
} else {
if (!config.isHandleImplicitRequests()) {
return null;
}
}
}
}
if (pathSegments.size() > 0) {
lastPathSegment = pathSegments.get(pathSegments.size() - 1);
} else {
lastPathSegment = null;
}
PathEntity pathEntity = null;
List<String> allPathSegments = new ArrayList<>(pathSegments);
Timestamp moment = UtilDateTime.nowTimestamp();
if (UtilValidate.isNotEmpty(lastPathSegment) && !urlSuffixFail) {
String firstPathSegment = (pathSegments.size() > 0) ? pathSegments.get(0) : null;
try {
if (explicitProductRequest) {
// EXPLICIT PRODUCT
// NOTE: Either the first path segment, last, or both may refer to a product, depending on configuration; simply try to handle all cases
// since we have to isolate the category trail and support all configs
// TODO: Support config options to constrain this behavior for optimization purposes
PathSegmentEntities firstSegmentProducts = null;
PathSegmentEntities lastSegmentProducts = null;
if (getConfig().isProductSimpleIdLookup() && UtilValidate.isNotEmpty(firstPathSegment)) {
firstSegmentProducts = UtilValidate.nullIfEmpty(matchPathSegmentProductCached(delegator, firstPathSegment, PathSegmentMatchOptions.ID_ONLY, moment));
}
if ((firstSegmentProducts == null || firstSegmentProducts.isEmpty() || pathSegments.size() >= 2) && UtilValidate.isNotEmpty(lastPathSegment)) {
// Always check this even if the first one matched
lastSegmentProducts = UtilValidate.nullIfEmpty(matchPathSegmentProductCached(delegator, lastPathSegment, PathSegmentMatchOptions.ANY, moment));
}
PathSegmentEntities targetProducts = null;
if (firstSegmentProducts != null) {
targetProducts = firstSegmentProducts;
pathSegments.remove(0);
if (lastSegmentProducts != null) {
pathSegments.remove(pathSegments.size() - 1);
}
} else if (lastSegmentProducts != null) {
targetProducts = lastSegmentProducts;
pathSegments.remove(pathSegments.size() - 1);
}
if (targetProducts != null) {
pathEntity = matchBestProductAndTrail(delegator, targetProducts, lastSegmentProducts, pathSegments, currentCatalogId, webSiteId, explicitLocale, moment);
}
} else if (explicitCategoryRequest) {
// EXPLICIT CATEGORY
PathSegmentEntities firstPathCategories = null;
PathSegmentEntities lastPathCategories = null;
if (getConfig().isCategorySimpleIdLookup() && UtilValidate.isNotEmpty(firstPathSegment)) {
firstPathCategories = UtilValidate.nullIfEmpty(matchPathSegmentCategoryCached(delegator, firstPathSegment, PathSegmentMatchOptions.ID_ONLY, moment));
}
if ((firstPathCategories == null || firstPathCategories.isEmpty() || pathSegments.size() >= 2) && UtilValidate.isNotEmpty(lastPathSegment)) {
lastPathCategories = UtilValidate.nullIfEmpty(matchPathSegmentCategoryCached(delegator, lastPathSegment, PathSegmentMatchOptions.ANY, moment));
}
PathSegmentEntities targetCategories = null;
if (firstPathCategories != null) {
targetCategories = firstPathCategories;
pathSegments.remove(0);
if (lastPathCategories != null) {
pathSegments.remove(pathSegments.size() - 1);
}
} else if (lastPathCategories != null) {
targetCategories = lastPathCategories;
pathSegments.remove(pathSegments.size() - 1);
}
if (targetCategories != null) {
pathEntity = matchBestCategoryAndTrail(delegator, targetCategories, lastPathCategories, pathSegments, currentCatalogId, webSiteId, explicitLocale, moment);
}
} else {
// IMPLICIT REQUEST
// WARN: best-effort, ambiguous - it is up to SeoConfig.xml to decide how risky this will be
PathSegmentMatchOptions matchOptions = config.isImplicitRequestNameMatchesOnly() ? PathSegmentMatchOptions.REQUIRE_NAME : PathSegmentMatchOptions.ANY;
PathSegmentEntities targetProducts = matchPathSegmentProductCached(delegator, lastPathSegment, matchOptions, moment);
if (targetProducts.size() > 0) {
pathSegments.remove(pathSegments.size() - 1);
pathEntity = matchBestProductAndTrail(delegator, targetProducts, targetProducts, pathSegments, currentCatalogId, webSiteId, null, moment);
} else {
PathSegmentEntities targetCategories = matchPathSegmentCategoryCached(delegator, lastPathSegment, matchOptions, moment);
if (targetCategories.size() > 0) {
pathSegments.remove(pathSegments.size() - 1);
pathEntity = matchBestCategoryAndTrail(delegator, targetCategories, targetCategories, pathSegments, currentCatalogId, webSiteId, null, moment);
}
}
}
} catch (Exception e) {
Debug.logError(e, "Seo: matchInboundSeoCatalogUrl: Error parsing catalog URL " + path + ": " + e.getMessage(), module);
return null;
}
}
return makePathMatchIfValidRequest(delegator, pathEntity, path, allPathSegments, contextPath, webSiteId, currentCatalogId, explicitProductRequest, explicitCategoryRequest, explicitLocale, moment);
}
/**
* Refers to the {@link PathEntity#getTrailCategoryIds()} selection, based on attempted matching, referring to inputs and selection among category rollup trails.
*/
public enum TrailMatchType {
EXACT(5),
PARTIAL(4),
NONE(3),
OUTSIDE_CATALOG(2),
INVALID(1);
private final int priority;
TrailMatchType(int priority) {
this.priority = priority;
}
private int getPriority() {
return priority;
}
public int comparePriorityTo(TrailMatchType other) {
return (other != null) ? Integer.compare(this.getPriority(), other.getPriority()) : 1;
}
public boolean isValidRequest() {
return (this == EXACT || this == PARTIAL || this == NONE);
}
public boolean isValidNonEmptyRequestTrail() {
return (this == EXACT || this == PARTIAL);
}
}
/**
* Local class used to return path segment entity and trailCategoryIds together from the methods.
*/
public static class PathEntity implements Serializable {
protected final TrailMatchType trailMatchType;
protected final PathSegmentEntity targetEntity;
protected final PathSegmentEntities lastSegmentEntities;
protected transient PathSegmentEntities lastSegmentTargetEntities;
protected final List<PathSegmentEntity> trailEntities;
protected final List<String> trailCategoryIds;
protected transient List<String> fullTrailCategoryIds;
protected final List<PathSegmentEntities> requestedTrailEntities;
protected transient List<PathSegmentEntities> fullRequestedTrailEntities;
protected PathEntity(TrailMatchType trailMatchType, PathSegmentEntity targetEntity, PathSegmentEntities lastSegmentEntities, List<PathSegmentEntity> trailEntities,
List<String> pathCategoryIds, List<PathSegmentEntities> requestedTrailEntities) {
this.trailMatchType = trailMatchType;
this.targetEntity = targetEntity;
this.lastSegmentEntities = lastSegmentEntities;
this.trailEntities = trailEntities;
this.trailCategoryIds = pathCategoryIds;
this.requestedTrailEntities = requestedTrailEntities;
}
public TrailMatchType getTrailMatchType() {
return trailMatchType;
}
public PathSegmentEntity getTargetEntity() {
return targetEntity;
}
public String getId() {
PathSegmentEntity targetEntity = getTargetEntity();
return (targetEntity != null) ? targetEntity.getId() : null;
}
/**
* Last segment (target) entities; normally contains {@link #getTargetEntity()} by ID but not guaranteed.
*/
public PathSegmentEntities getLastSegmentEntities() {
return lastSegmentEntities;
}
/**
* Sometimes, the {@link #getLastSegmentTargetEntities()} entity was not the last path segment; this gets the first matching in the last path segment.
*/
public PathSegmentEntities getLastSegmentTargetEntities() {
PathSegmentEntities lastSegmentTargetEntities = this.lastSegmentTargetEntities;
if (lastSegmentTargetEntities == null) {
PathSegmentEntity targetEntity = getTargetEntity();
PathSegmentEntities lastSegmentEntities = getLastSegmentEntities();
if (targetEntity != null && lastSegmentEntities != null) {
lastSegmentTargetEntities = lastSegmentEntities.getAllForId(targetEntity.getId());
this.lastSegmentTargetEntities = lastSegmentTargetEntities;
}
}
return lastSegmentTargetEntities;
}
public CatalogUrlType getTargetEntityType() {
return getTargetEntity().getEntityType();
}
/**
* Returns true if either no trail was present in the request (and this is allowed by config) or if all trail elements
* matched or full or partial category rollup trail for the target category or product.
*/
public boolean isValidRequest() {
return getTrailMatchType().isValidRequest();
}
/**
* Returns the category trail entity matches, excluding the target product and target category if this is a category path;
* same size as {@link #getTrailCategoryIds()} unless no path was specific (and this is allowed) and no default trail was chosen,
* in which case is empty list.
*
* <p>May return null if invalid trail entities; if empty list, is a validated no-trail request, but it's better to check
* {@link }.</p>
*
* <p>NOTE: This only returns a non-empty list if the entities were successfully matched; if you want to check the original
* request entities whether or not they matched successfully to the trail returned by {@link #getTrailCategoryIds()},
* see {@link #getRequestedTrailEntities()} instead.</p>
*/
public List<PathSegmentEntity> getTrailEntities() {
return trailEntities;
}
/**
* Returns the category trail, excluding the target category if this is a category path.
*
* <p>May return null if invalid trail or outside catalog (where permitted by config).</p>
*/
public List<String> getTrailCategoryIds() {
return trailCategoryIds;
}
/**
* Returns the category trail, including the target category if this is a category path (excluding if product).
*/
public List<String> getFullTrailCategoryIds() {
List<String> fullTrailCategoryIds = this.fullTrailCategoryIds;
if (fullTrailCategoryIds == null) {
List<String> trailCategoryIds = getTrailCategoryIds();
PathSegmentEntity entity = getTargetEntity();
if (entity != null) {
if (entity.getEntityType() == CatalogUrlType.PRODUCT) {
fullTrailCategoryIds = trailCategoryIds;
} else {
if (UtilValidate.isNotEmpty(trailCategoryIds)) {
fullTrailCategoryIds = new ArrayList<>(trailCategoryIds.size() + 1);
fullTrailCategoryIds.addAll(trailCategoryIds);
fullTrailCategoryIds.add(entity.getId());
} else {
fullTrailCategoryIds = List.of(entity.getId());
}
}
this.fullTrailCategoryIds = fullTrailCategoryIds;
}
}
return fullTrailCategoryIds;
}
/**
* Returns the originally requested trail entities, excluding the entry for the target entity; these may be wrong length (different than {@link #getTrailCategoryIds()}) or not match the chosen trail entities/categories.
*/
public List<PathSegmentEntities> getRequestedTrailEntities() {
return requestedTrailEntities;
}
/**
* Returns the originally requested trail entities, including the entries for the target entity or extra entities; these may be wrong length (different than {@link #getFullTrailCategoryIds()}) or not match the chosen trail entities/categories.
*/
public List<PathSegmentEntities> getFullRequestedTrailEntities() {
List<PathSegmentEntities> fullRequestedTrailEntities = this.fullRequestedTrailEntities;
if (fullRequestedTrailEntities == null) {
if (getTargetEntityType() == CatalogUrlType.PRODUCT) {
fullRequestedTrailEntities = requestedTrailEntities;
} else {
PathSegmentEntities lastEntities = (this.lastSegmentEntities != null && this.lastSegmentEntities.size() > 0) ?
this.lastSegmentEntities : targetEntity.asEntitiesResult();
if (UtilValidate.isNotEmpty(requestedTrailEntities)) {
fullRequestedTrailEntities = new ArrayList<>(requestedTrailEntities.size() + 1);
fullRequestedTrailEntities.addAll(requestedTrailEntities);
fullRequestedTrailEntities.add(lastEntities);
} else {
fullRequestedTrailEntities = List.of(lastEntities);
}
}
this.fullRequestedTrailEntities = fullRequestedTrailEntities;
}
return fullRequestedTrailEntities;
}
}
protected PathEntity matchBestProductAndTrail(Delegator delegator, PathSegmentEntities products, PathSegmentEntities lastSegmentProducts, List<String> pathSegments, String currentCatalogId, String webSiteId, Locale expectedLocale, Timestamp moment) throws GenericEntityException {
return matchBestEntityAndTrail(delegator, products, lastSegmentProducts, pathSegments, currentCatalogId, webSiteId, expectedLocale, moment);
}
protected PathEntity matchBestCategoryAndTrail(Delegator delegator, PathSegmentEntities categories, PathSegmentEntities lastSegmentCategories, List<String> pathSegments, String currentCatalogId, String webSiteId, Locale expectedLocale, Timestamp moment) throws GenericEntityException {
return matchBestEntityAndTrail(delegator, categories, lastSegmentCategories, pathSegments, currentCatalogId, webSiteId, expectedLocale, moment);
}
/**
* Uses heuristic category trail matching to determine best entity and trail for a set of target entities against category rollup trails.
*
* <p>NOTE: This assumes the targetEntities are all product or all categories.</p>
*
* <p>TODO: This should use expectedLocale when non-null to bias targetEntities and trail selection.</p>
*/
protected PathEntity matchBestEntityAndTrail(Delegator delegator, PathSegmentEntities targetEntities, PathSegmentEntities lastSegmentEntities,
List<String> pathSegments, String currentCatalogId, String webSiteId, Locale expectedLocale, Timestamp moment) throws GenericEntityException {
PathSegmentMatchOptions matchOptions = PathSegmentMatchOptions.ANY; // TODO: Configurable
List<PathSegmentEntities> requestedTrailEntities = matchPathSegmentCategoriesCached(delegator, pathSegments, matchOptions, moment);
Set<String> topCategoryIds;
if (targetEntities.getEntityType() == CatalogUrlType.CATEGORY) {
topCategoryIds = getCatalogTopCategoriesForCategoryUrl(delegator, currentCatalogId, webSiteId);
} else {
topCategoryIds = getCatalogTopCategoriesForProductUrl(delegator, currentCatalogId, webSiteId);
}
PathSegmentEntity bestEntity = null;
List<PathSegmentEntity> bestTrailEntities = null;
List<List<String>> bestTrails = null;
List<String> bestTrail = null; // NOTE: this contains the target category itself at end
TrailMatchType bestTrailMatchType = null;
for (PathSegmentEntity entity : targetEntities) {
boolean isCategoryEntity = (entity.getEntityType() == CatalogUrlType.CATEGORY);
List<PathSegmentEntity> trailEntities;
List<List<String>> trails;
List<String> trail;
TrailMatchType trailMatchType;
if (isCategoryEntity) {
trails = getCategoryRollupTrails(delegator, entity.getId(), topCategoryIds);
} else {
trails = getProductRollupTrails(delegator, entity.getId(), topCategoryIds);
}
if (trails.size() > 0) {
if (pathSegments.size() > 0) {
if (isCategoryEntity) {
// SPECIAL: for category, we have to re-add ourselves at the end for trail matching purposes (just modify list in-place for now)
requestedTrailEntities.add(targetEntities);
}
try {
trail = findBestTrailForPathSegments(delegator, trails, requestedTrailEntities, expectedLocale);
} finally {
if (isCategoryEntity) {
requestedTrailEntities.remove(requestedTrailEntities.size() - 1);
}
}
if (trail != null) {
if (isCategoryEntity) {
trail.remove(trail.size() - 1);
}
trailMatchType = (trail.size() == pathSegments.size()) ? TrailMatchType.EXACT : TrailMatchType.PARTIAL;
// NOTE: Here, trail returned by findBestTrailForPathSegments is always equal size or longer than pathSegments,
// so the best trail is actually the shortest one, because it's closest to pathSegments (we're not comparing pathSegments)
// - this is only true when comparing EXACT/PARTIAL matches
// TODO: REVIEW: Here, exact trail size is prioritized over top category ProdCatalogCategory sequenceNum, which is probably wanted but debatable
if (bestEntity == null || bestTrail == null || bestTrailMatchType.comparePriorityTo(trailMatchType) < 0 ||
(bestTrailMatchType.comparePriorityTo(trailMatchType) == 0 &&
(trail.size() < bestTrail.size() ||
(trail.size() == bestTrail.size() && isFirstTrailBetterMatchThanSecondTopCatPrecision(entity, trail, bestEntity, bestTrail, topCategoryIds))))) {
trailEntities = resolvePathSegmentEntityTrail(trail, requestedTrailEntities);
bestEntity = entity;
bestTrailEntities = trailEntities;
bestTrails = trails;
bestTrail = trail;
bestTrailMatchType = trailMatchType;
}
} else { // Match failure - invalid category trail passed - use default
if (getConfig().isAllowInvalidCategoryPathElements()) {
trailMatchType = TrailMatchType.INVALID;
trail = getFirstTopTrail(trails, topCategoryIds);
trailEntities = null; // null when invalid
// NOTE: Here the precision check is comparing two INVALID matches (meaning two first top trails)
// so we're just picking a consistent default fallback for INVALID
if (bestEntity == null || bestTrailMatchType.comparePriorityTo(trailMatchType) < 0 ||
(bestTrailMatchType.comparePriorityTo(trailMatchType) == 0 &&
isFirstTrailBetterMatchThanSecondTopCatPrecision(entity, trail, bestEntity, bestTrail, topCategoryIds))) {
bestEntity = entity;
bestTrailEntities = trailEntities;
bestTrails = trails;
bestTrail = trail;
bestTrailMatchType = trailMatchType;
}
}
}
} else { // (trails.size() > 0) && (pathSegments.size() == 0)
// No path specified (single product or category ID/name) - fallback to default trail
trail = getFirstTopTrail(trails, topCategoryIds);
trailEntities = List.of(); // explicit empty (none requested) - not invalid
if (trail != null) {
trailMatchType = TrailMatchType.NONE;
} else { // Shouldn't happen because (trails.size() > 0), but just in case
if (getConfig().isAllowTargetOutsideCatalog()) {
trailMatchType = TrailMatchType.OUTSIDE_CATALOG;
} else {
trailMatchType = null; // Don't consider result
}
}
if (trailMatchType != null && (bestEntity == null || bestTrailMatchType.comparePriorityTo(trailMatchType) < 0 ||
(bestTrailMatchType.comparePriorityTo(trailMatchType) == 0 &&
isFirstTrailBetterMatchThanSecondTopCatPrecision(entity, trail, bestEntity, bestTrail, topCategoryIds)))) {
bestEntity = entity;
bestTrailEntities = trailEntities;
bestTrails = trails;
bestTrail = trail;
bestTrailMatchType = trailMatchType;
}
}
} else { // // (trails.size() == 0)
// Unexpected: this product/category is not connected to the catalog through rollup/topCategoryIds
if (getConfig().isAllowTargetOutsideCatalog()) {
trail = null;
if (pathSegments.isEmpty()) {
trailMatchType = TrailMatchType.OUTSIDE_CATALOG;
trailEntities = List.of(); // explicit empty (none requested) - not invalid
} else {
if (getConfig().isAllowInvalidCategoryPathElements()) {
trailMatchType = TrailMatchType.INVALID;
} else {
trailMatchType = null; // Don't consider result
}
trailEntities = null; // null when invalid
}
if ((trailMatchType != null) && (bestEntity == null || bestTrailMatchType.comparePriorityTo(trailMatchType) < 0 ||
(bestTrailMatchType.comparePriorityTo(trailMatchType) == 0 &&
isFirstTrailBetterMatchThanSecondTopCatPrecision(entity, trail, bestEntity, bestTrail, topCategoryIds)))) {
bestEntity = entity;
bestTrailEntities = trailEntities;
bestTrails = trails;
bestTrail = trail;
bestTrailMatchType = trailMatchType;
}
}
}
}
return (bestEntity != null) ? new PathEntity(bestTrailMatchType, bestEntity, lastSegmentEntities, bestTrailEntities, bestTrail, requestedTrailEntities) : null;
}
/**
* Does top cat check and precision check, but assumes size was already checked.
*/
private boolean isFirstTrailBetterMatchThanSecondTopCatPrecision(PathSegmentEntity firstMatch, List<String> firstTrail, PathSegmentEntity secondMatch, List<String> secondTrail, Set<String> topCategoryIds) {
if (UtilValidate.isNotEmpty(firstTrail)) {
if (UtilValidate.isNotEmpty(secondTrail)) {
// 1) prefer the trail that is lower ProdCatalogCategory sequenceNum
String firstTopCatId = firstTrail.get(0);
String secondTopCatId = secondTrail.get(0);
if (!firstTopCatId.equals(secondTopCatId)) {
for (String topCatId : topCategoryIds) {
// first to hit returns
if (firstTopCatId.equals(topCatId)) {
return true;
} else if (secondTopCatId.equals(topCatId)) {
return false;
}
}
}
} else {
return true;
}
} else {
if (UtilValidate.isNotEmpty(secondTrail)) {
return false;
}
}
// fallback on precision check
return firstMatch.isMorePreciseThan(secondMatch);
}
/**
* Checks if matches suffix and has starting slash; removes starting and ending slashes.
*
* <p>Returns null if bad. Result splits cleanly on "/".</p>
*/
public String preprocessInboundSeoCatalogUrlPath(String pathInfo) {
// path must start with a slash, and remove it
if (!pathInfo.startsWith("/")) return null;
pathInfo = pathInfo.substring(1);
// if path ends with slash (for whatever reason it was added), remove it
if (pathInfo.endsWith("/")) pathInfo = pathInfo.substring(0, pathInfo.length() - 1);
return pathInfo;
}
/**
* Uses the passed path elements (resolved) to try to select the best of the possible trails.
*
* <p>Returns null only if nothing matches at all. Best-effort.</p>
*
* <p>TODO: REVIEW: the possibility of each path elem matching multiple category IDs makes this extremely
* complicated; so we ignore the specific implications and just match as much as possible.</p>
*
* <p>For a trail to be selected, it must "end with" the pathElems; after that, the best trail is one that
* has smallest length.</p>
*
* <p>TODO: This should use expectedLocale when non-null to bias targetEntities and trail selection.</p>
*/
protected List<String> findBestTrailForPathSegments(Delegator delegator, List<List<String>> possibleTrails, List<PathSegmentEntities> pathSegments, Locale expectedLocale) throws GenericEntityException {
if (pathSegments.isEmpty()) {
return null;
}
List<String> bestTrail = null;
for(List<String> trail : possibleTrails) {
if (pathSegments.size() > trail.size()) {
continue; // sure to fail
}
ListIterator<PathSegmentEntities> pit = pathSegments.listIterator(pathSegments.size());
ListIterator<String> tit = trail.listIterator(trail.size());
boolean matched = true;
while (matched && pit.hasPrevious()) {
PathSegmentEntities trailSegmentEntities = pit.previous();
if (trailSegmentEntities == null) {
matched = false;
} else {
String categoryId = tit.previous();
// TODO: Configurable: simplistic check: ignores exact vs name-only matches, but may be good enough
PathSegmentEntity trailSegmentEntity = trailSegmentEntities.getForId(categoryId);
if (trailSegmentEntity == null) {
matched = false;
}
}
}
if (matched) {
if (trail.size() == pathSegments.size()) { // ideal case
bestTrail = trail;
break;
} else if (bestTrail == null || trail.size() < bestTrail.size()) { // smaller = better
bestTrail = trail;
}
}
}
return bestTrail;
}
/**
* Based on {@link #findBestTrailForPathSegments}, should be called afterward.
*/
protected List<PathSegmentEntity> resolvePathSegmentEntityTrail(List<String> trail, List<PathSegmentEntities> requestedTrailEntities) throws GenericEntityException {
if (requestedTrailEntities == null || requestedTrailEntities.isEmpty()) {
return List.of();
} else if (trail == null || requestedTrailEntities.size() > trail.size()) {
throw new IllegalArgumentException("Invalid category trail for path segments");
}
List<PathSegmentEntity> entityTrail = new ArrayList<>(requestedTrailEntities.size());
ListIterator<PathSegmentEntities> pit = requestedTrailEntities.listIterator();
ListIterator<String> tit = trail.listIterator(trail.size() - requestedTrailEntities.size());
while (pit.hasNext()) {
PathSegmentEntities trailSegmentEntities = pit.next();
String id = tit.next();
PathSegmentEntity trailSegmentEntity = trailSegmentEntities.getForId(id);
if (trailSegmentEntity == null) {
throw new IllegalArgumentException("Invalid category trail for path segments (ID not found in trail path segment entities: " + id + ")");
}
entityTrail.add(trailSegmentEntity);
}
return entityTrail;
}
/*
* *****************************************************
* Alternative URL individual path elem part parsing
* *****************************************************
*/
public static class PathSegmentMatchOptions implements Serializable {
public static final PathSegmentMatchOptions ANY = new PathSegmentMatchOptions(false, true, true);
public static final PathSegmentMatchOptions REQUIRE_NAME = new PathSegmentMatchOptions(false, false, true);
public static final PathSegmentMatchOptions ID_ONLY = new PathSegmentMatchOptions(true, true, false);
private final boolean requireId;
private final boolean allowIdOnly;
private final boolean allowName;
private final String cacheKey;
protected PathSegmentMatchOptions(boolean requireId, boolean allowIdOnly, boolean allowName) {
this.requireId = requireId;
this.allowIdOnly = allowIdOnly;
this.allowName = allowName;
this.cacheKey = (requireId ? "Y" : "N") + ":" + (allowIdOnly ? "Y" : "N") + ":" + (allowName ? "Y" : "N");
}
public boolean isRequireId() {
return requireId;
}
public boolean isAllowIdOnly() {
return allowIdOnly;
}
public boolean isAllowName() {
return allowName;
}
public String getCacheKey() {
return cacheKey;
}
}
/**
* All the entities a given path part may map to.
*
* <p>NOTE: This is most often size 1 but may map to multiple products/categories and is optimized for caching.</p>
*/
public static class PathSegmentEntities implements List<PathSegmentEntity>, Serializable {
private final CatalogUrlType entityType;
private final String pathSegment;
private final List<PathSegmentEntity> entities;
private final Timestamp moment;
public PathSegmentEntities(CatalogUrlType entityType, String pathSegment, Collection<PathSegmentEntity> entities, Timestamp moment) {
this.entityType = entityType;
this.pathSegment = pathSegment;
this.entities = List.copyOf(entities); // Optimize for caching (never modified after this)
this.moment = moment;
}
public PathSegmentEntities(PathSegmentEntity single, Timestamp moment) {
this.entityType = single.getEntityType();
this.pathSegment = single.getPathSegment();
this.entities = List.of(single);
this.moment = moment;
}
public CatalogUrlType getEntityType() {
return entityType;
}
/**
* The path we matched against (*should* be the same for all the entities matched for a path segment).
*/
public String getPathSegment() {
return pathSegment;
}
public List<PathSegmentEntity> getEntities() {
return entities;
}
public Timestamp getMoment() {
return moment;
}
public PathSegmentEntity getForId(String id) {
for (PathSegmentEntity entity : getEntities()) {
if (id.equals(entity.getId())) {
return entity;
}
}
return null;
}
public PathSegmentEntities getAllForId(String id) {
List<PathSegmentEntity> targetEntities = getEntities().stream().filter(e -> id.equals(e.getId())).collect(Collectors.toList());
return new PathSegmentEntities(this.entityType, this.pathSegment, targetEntities, this.moment);
}
public List<String> getIdList() {
return getEntities().stream().map(PathSegmentEntity::getId).collect(Collectors.toList());
}
public boolean isExpired(Timestamp moment, long expireTimeMs) {
return moment.before(UtilDateTime.addMillisecondsToTimestamp(this.moment, (int) expireTimeMs));
}
public boolean sameEntities(PathSegmentEntities other) {
if (entities.size() != other.entities.size()) {
return false;
}
for (PathSegmentEntity entity : other.entities) {
if (this.getForId(entity.getId()) == null) {
return false;
}
}
return true;
}
/**
* @deprecated This should not be needed anymore because the options are included in the caches (faster at cost of more memory)
*/
@Deprecated
public PathSegmentEntities filterResults(PathSegmentMatchOptions matchOptions, Timestamp moment) {
List<PathSegmentEntity> newEntities = null;
for (PathSegmentEntity entity : getEntities()) {
PathSegmentEntity newEntity = entity.filterResults(matchOptions);
if (newEntity != null) {
if (newEntities == null) {
newEntities = new ArrayList<>(entities.size());
}
newEntities.add(newEntity);
}
}
return (newEntities != null) ? new PathSegmentEntities(getEntityType(), getPathSegment(), newEntities, moment) : null;
}
@Override
public int size() {
return entities.size();
}
@Override
public boolean isEmpty() {
return entities.isEmpty();
}
@Override
public boolean contains(Object o) {
return entities.contains(o);
}
@Override
public Iterator<PathSegmentEntity> iterator() {
return entities.iterator();
}
@Override
public Object[] toArray() {
return entities.toArray();
}
@Override
public <T> T[] toArray(T[] a) {
return entities.toArray(a);
}
@Override
public boolean add(PathSegmentEntity pathSegmentEntity) {
throw new UnsupportedOperationException();
}
@Override
public boolean remove(Object o) {
throw new UnsupportedOperationException();
}
@Override
public boolean containsAll(Collection<?> c) {
return entities.containsAll(c);
}
@Override
public boolean addAll(Collection<? extends PathSegmentEntity> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean addAll(int index, Collection<? extends PathSegmentEntity> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean removeAll(Collection<?> c) {
throw new UnsupportedOperationException();
}
@Override
public boolean retainAll(Collection<?> c) {
throw new UnsupportedOperationException();
}
@Override
public void clear() {
throw new UnsupportedOperationException();
}
@Override
public PathSegmentEntity get(int index) {
return entities.get(index);
}
@Override
public PathSegmentEntity set(int index, PathSegmentEntity element) {
throw new UnsupportedOperationException();
}
@Override
public void add(int index, PathSegmentEntity element) {
throw new UnsupportedOperationException();
}
@Override
public PathSegmentEntity remove(int index) {
throw new UnsupportedOperationException();
}
@Override
public int indexOf(Object o) {
return entities.indexOf(o);
}
@Override
public int lastIndexOf(Object o) {
return entities.lastIndexOf(o);
}
@Override
public ListIterator<PathSegmentEntity> listIterator() {
return entities.listIterator();
}
@Override
public ListIterator<PathSegmentEntity> listIterator(int index) {
return entities.listIterator(index);
}
@Override
public List<PathSegmentEntity> subList(int fromIndex, int toIndex) {
return entities.subList(fromIndex, toIndex);
}
}
protected <C extends Collection<PathSegmentEntity>> C optimizePathSegmentEntitiesReadOnly(C entities) {
for (PathSegmentEntity entity : entities) {
entity.optimizeReadOnly();
}
return entities;
}
public PathSegmentEntity makePathSegmentEntity(Delegator delegator, CatalogUrlType entityType, String id, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
return new PathSegmentEntity(delegator, entityType, id, pathSegment, matchOptions, moment, matchType, name, localeString, record);
}
protected PathSegmentEntity addPathSegmentEntity(Map<String, PathSegmentEntity> results, Delegator delegator, CatalogUrlType entityType, String id, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
PathSegmentEntity match = results.get(id);
if (match != null) {
match.addRecordMatch(matchType, name, localeString, record);
} else {
match = makePathSegmentEntity(delegator, entityType, id, pathSegment, matchOptions, moment, matchType, name, localeString, record);
results.put(id, match);
}
return match;
}
/**
* A path segment (alt url, id or alt url + id) mapping to an entity along with all its record matches (may be several for multiple locales, etc).
*/
public static class PathSegmentEntity implements Serializable {
protected final CatalogUrlType entityType;
protected final String id;
protected final String pathSegment;
protected final PathSegmentMatchOptions matchOptions;
protected final Timestamp moment; // NOTE: This is only for internal query use - this gets cached so client code may not want to reuse
protected final String delegatorName;
protected transient Delegator delegator;
/**
* Individual record/name matches, most precise first.
*
* <p>NOTE: thread safety requires safe publishing (don't modify after cache store).</p>
*/
protected List<RecordMatch> recordMatches; // Not needed: volatile
/**
* Caches all ALTERNATIVE_URL shortened record values for this ID (not just matched textData or locale).
*/
protected List<GenericValue> mainContentAltUrlValues;
/**
* Caches all ALTERNATIVE_URL ALTERNATE_LOCALE shortened record values for this ID (not just matched textData or locale).
*/
protected List<GenericValue> altLocaleContentAltUrlValues;
protected Map<String, GenericValue> localeContentAltUrlValueMap;
protected PathSegmentEntity(Delegator delegator, CatalogUrlType entityType, String id, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
this.entityType = entityType;
this.id = UtilValidate.nullIfEmpty(id);
this.pathSegment = pathSegment;
this.matchOptions = matchOptions;
this.moment = moment;
this.delegatorName = delegator.getDelegatorName();
this.delegator = delegator;
List<RecordMatch> recordMatches = new ArrayList<>(1);
recordMatches.add(makeRecordMatch(matchType, name, localeString, record));
this.recordMatches = recordMatches;
}
protected PathSegmentEntity(PathSegmentEntity other, PathSegmentMatchOptions matchOptions, boolean preserveRecordMatches) {
this.entityType = other.entityType;
this.id = other.id;
this.pathSegment = other.pathSegment;
this.matchOptions = (matchOptions != null) ? matchOptions : other.matchOptions;
this.moment = other.moment;
this.delegatorName = other.delegatorName;
this.delegator = other.delegator;
if (preserveRecordMatches) {
List<RecordMatch> recordMatches = new ArrayList<>(other.recordMatches.size());
for (RecordMatch recordMatch : other.getRecordMatches()) {
recordMatches.add(makeRecordMatch(recordMatch));
}
this.recordMatches = recordMatches;
} else {
this.recordMatches = new ArrayList<>(other.recordMatches.size());
}
}
protected PathSegmentEntity makePathSegmentEntity(PathSegmentEntity other, PathSegmentMatchOptions matchOptions, boolean preserveMatches) {
return new PathSegmentEntity(other, matchOptions, preserveMatches);
}
public RecordMatch makeRecordMatch(PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
return new RecordMatch(matchType, name, localeString, record);
}
public RecordMatch makeRecordMatch(RecordMatch other) {
return new RecordMatch(other);
}
public PathSegmentEntity addRecordMatch(RecordMatch recordMatch) {
recordMatches.add(recordMatch);
sortRecordMatches();
return this;
}
public PathSegmentEntity addRecordMatch(PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
return addRecordMatch(makeRecordMatch(matchType, name, localeString, record));
}
protected PathSegmentEntity addRecordMatchCopyUnsorted(RecordMatch recordMatch) {
addRecordMatch(makeRecordMatch(recordMatch));
return this;
}
public PathSegmentEntity sortRecordMatches() {
recordMatches.sort((m1, m2) -> m2.comparePrecisionTo(m1));
return this;
}
protected void optimizeReadOnly() {
if (recordMatches instanceof ArrayList) {
recordMatches = List.copyOf(recordMatches);
}
}
protected Delegator getDelegator() {
Delegator delegator = this.delegator;
if (delegator == null) {
delegator = Delegator.fromName(delegatorName);
this.delegator = delegator;
}
return delegator;
}
public boolean isValid() {
return (getId() != null);
}
/**
* The type of target we found for this link: product or catalog.
*/
public CatalogUrlType getEntityType() {
return entityType;
}
/**
* The path we matched against (*should* be the same for all entities matched for a path segment).
*/
public String getPathSegment() {
return pathSegment;
}
/**
* The extraction options we used to produce this match.
*/
public PathSegmentMatchOptions getMatchOptions() {
return matchOptions;
}
/**
* Gets the individual successful matches to this entity/record for this path part.
*
* <p>Often this will be size 1, but may be multiple for multiple matching locales with same alt URL, productIds also used as names, etc.</p>
*/
public List<RecordMatch> getRecordMatches() {
return recordMatches;
}
public boolean hasRecordMatches() {
return (getRecordMatches().size() > 0);
}
/**
* The ID from DB (NOT from the orig URL).
*/
public String getId() {
return id;
}
public PathSegmentMatchType getBestMatchType() {
return getRecordMatches().get(0).getMatchType();
}
/**
* NOTE: for our purposes, we consider name+id more precise than id only.
*/
public boolean isMorePreciseThan(PathSegmentEntity other) {
return this.getBestMatchType().isMorePreciseThan(other.getBestMatchType());
}
public int comparePrecisionTo(PathSegmentEntity other) {
return this.getBestMatchType().comparePrecisionTo(other.getBestMatchType());
}
public PathSegmentEntities asEntitiesResult() {
return new PathSegmentEntities(this, moment);
}
/**
* Returns all ALTERNATIVE_URL shortened record values for this ID (not just matched textData or locale).
*/
public List<GenericValue> getMainContentAltUrlValues() {
return getMainContentAltUrlValues(null);
}
/**
* Returns all ALTERNATIVE_URL shortened record values for this ID (not just matched textData or locale).
*/
public List<GenericValue> getMainContentAltUrlValues(Boolean minimalSelect) {
List<GenericValue> mainContentAltUrlValues = this.mainContentAltUrlValues;
if (mainContentAltUrlValues == null ||
(Boolean.FALSE.equals(minimalSelect) && mainContentAltUrlValues.size() > 0 && !mainContentAltUrlValues.get(0).containsKey("ownerContentId"))) {
try {
if (getEntityType() == CatalogUrlType.PRODUCT) {
mainContentAltUrlValues = findProductContentAltUrlValues(getDelegator(),
EntityCondition.makeCondition("productId", getId()), moment, minimalSelect != null ? minimalSelect : true);
} else {
mainContentAltUrlValues = findProductCategoryContentAltUrlValues(getDelegator(),
EntityCondition.makeCondition("productCategoryId", getId()), moment, minimalSelect != null ? minimalSelect : true);
}
} catch (GenericEntityException e) {
Debug.logError(e, module);
this.mainContentAltUrlValues = List.of();
return null;
}
this.mainContentAltUrlValues = mainContentAltUrlValues;
}
return mainContentAltUrlValues;
}
/**
* Returns all ALTERNATIVE_URL ALTERNATE_LOCALE shortened record values for this ID (not just matched textData or locale).
*/
public List<GenericValue> getAltLocaleContentAltUrlValues() {
return getAltLocaleContentAltUrlValues(null);
}
/**
* Returns all ALTERNATIVE_URL ALTERNATE_LOCALE shortened record values for this ID (not just matched textData or locale).
*/
public List<GenericValue> getAltLocaleContentAltUrlValues(Boolean minimalSelect) {
List<GenericValue> altLocaleContentAltUrlValues = this.altLocaleContentAltUrlValues;
if (altLocaleContentAltUrlValues == null ||
(Boolean.FALSE.equals(minimalSelect) && altLocaleContentAltUrlValues.size() > 0 && !altLocaleContentAltUrlValues.get(0).containsKey("ownerContentId"))) {
try {
if (getEntityType() == CatalogUrlType.PRODUCT) {
altLocaleContentAltUrlValues = findProductContentAltUrlLocalizedValues(getDelegator(),
EntityCondition.makeCondition("productId", getId()), moment, minimalSelect != null ? minimalSelect : true);
} else {
altLocaleContentAltUrlValues = findProductCategoryContentAltUrlLocalizedValues(getDelegator(),
EntityCondition.makeCondition("productCategoryId", getId()), moment, minimalSelect != null ? minimalSelect : true);
}
} catch (GenericEntityException e) {
Debug.logError(e, module);
this.altLocaleContentAltUrlValues = List.of();
return null;
}
this.altLocaleContentAltUrlValues = altLocaleContentAltUrlValues;
}
return altLocaleContentAltUrlValues;
}
/**
* Returns an insertion-ordered map of locale strings to content values; empty string is supported and typically the default and first entry.
*/
public Map<String, GenericValue> getLocaleContentAltUrlValueMap() {
return getLocaleContentAltUrlValueMap(null);
}
/**
* Returns an insertion-ordered map of locale strings to content values; empty string is supported and typically the default and first entry.
*/
public Map<String, GenericValue> getLocaleContentAltUrlValueMap(Boolean minimalSelect) {
Map<String, GenericValue> localeContentAltUrlValueMap = this.localeContentAltUrlValueMap;
if (localeContentAltUrlValueMap == null ||
(Boolean.FALSE.equals(minimalSelect) && localeContentAltUrlValueMap.size() > 0 &&
!localeContentAltUrlValueMap.values().iterator().next().containsKey("ownerContentId"))) {
localeContentAltUrlValueMap = new LinkedHashMap<>();
for (GenericValue value : getMainContentAltUrlValues(minimalSelect)) {
String localeString = value.getString("localeString");
if (!localeContentAltUrlValueMap.containsKey(localeString)) {
localeContentAltUrlValueMap.put(localeString != null ? localeString : "", value);
}
}
for (GenericValue value : getAltLocaleContentAltUrlValues(minimalSelect)) {
String localeString = value.getString("localeString");
if (localeString != null) {
if (!localeContentAltUrlValueMap.containsKey(localeString)) {
localeContentAltUrlValueMap.put(localeString, value);
}
} else {
Debug.logWarning("Unexpected empty localeString on ALTERNATE_LOCALE content; ignoring: " + value, module);
}
}
localeContentAltUrlValueMap = Map.copyOf(localeContentAltUrlValueMap);
this.localeContentAltUrlValueMap = localeContentAltUrlValueMap;
}
return localeContentAltUrlValueMap;
}
/**
* @deprecated This should not be needed anymore because the options are included in the caches (faster at cost of more memory)
*/
@Deprecated
public PathSegmentEntity filterResults(PathSegmentMatchOptions matchOptions) {
if (matchOptions.isRequireId()) {
PathSegmentEntity newResults = null;
if (matchOptions.isAllowIdOnly()) {
for (RecordMatch recordMatch : getRecordMatches()) {
if (recordMatch.getMatchType().hasId()) {
if (newResults == null) {
newResults = makePathSegmentEntity(this, matchOptions, false);
}
newResults.addRecordMatchCopyUnsorted(recordMatch);
}
}
} else {
for (RecordMatch recordMatch : getRecordMatches()) {
if (recordMatch.getMatchType().isFull()) {
if (newResults == null) {
newResults = makePathSegmentEntity(this, matchOptions, false);
}
newResults.addRecordMatchCopyUnsorted(recordMatch);
}
}
}
return newResults;
} else {
if (matchOptions.isAllowIdOnly()) {
return this;
} else {
PathSegmentEntity newResults = null;
for (RecordMatch recordMatch : getRecordMatches()) {
if (recordMatch.getMatchType().isIdOnly()) {
if (newResults == null) {
newResults = makePathSegmentEntity(this, matchOptions, false);
}
newResults.addRecordMatchCopyUnsorted(recordMatch);
}
}
return newResults;
}
}
}
public static class RecordMatch implements Serializable {
protected final PathSegmentMatchType matchType;
protected final String name;
protected final String localeString;
protected transient Locale locale;
protected final GenericValue record;
protected RecordMatch(PathSegmentMatchType matchType, String name, String localeString, GenericValue record) {
this.matchType = matchType;
this.name = name;
this.localeString = localeString;
this.record = record;
}
protected RecordMatch(RecordMatch other) {
this.matchType = other.matchType;
this.name = other.name;
this.localeString = other.localeString;
this.locale = other.locale;
this.record = other.record;
}
public PathSegmentMatchType getMatchType() {
return matchType;
}
/**
* The name matched from DB (NOT from the orig URL), or null if it was an ID-only match.
*/
public String getName() { return name; }
/**
* The locale string from DB or null if none.
*/
public String getLocaleString() { return localeString; }
/**
* The locale from DB or null if none.
*/
public Locale getLocale() {
Locale locale = this.locale;
if (locale == null && localeString != null) {
locale = UtilMisc.parseLocale(localeString);
this.locale = locale;
}
return locale;
}
public GenericValue getRecord() {
return record;
}
public int comparePrecisionTo(RecordMatch other) {
return this.getMatchType().comparePrecisionTo(other.getMatchType());
}
}
}
public enum PathSegmentMatchType {
ID(2),
NAME(1),
NAME_ID(3);
private final int precision;
PathSegmentMatchType(int precision) {
this.precision = precision;
}
public boolean isFull() {
return (this == NAME_ID);
}
public boolean isIdOnly() {
return (this == ID);
}
public boolean hasId() {
return (this == ID || this == NAME_ID);
}
public boolean isNameOnly() {
return (this == NAME);
}
public boolean hasName() {
return (this == NAME || this == NAME_ID);
}
public boolean isMorePreciseThan(PathSegmentMatchType other) {
return (comparePrecisionTo(other) > 0);
}
public int comparePrecisionTo(PathSegmentMatchType other) {
if (other == null) {
return 1;
} else {
return Integer.compare(this.precision, other.precision);
}
}
}
/**
* SCIPIO: Tries to match an alt URL path element to a product and caches the results IF they match.
* Heavily modified logic from CatalogUrlFilter.
*/
public PathSegmentEntities matchPathSegmentProductCached(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment) throws GenericEntityException {
String key = delegator.getDelegatorName() + "::" + pathSegment + "::" + matchOptions.getCacheKey();
PathSegmentEntities results = productPathSegmentEntitiesCache.get(key);
if (results == null) {
results = matchPathSegmentProduct(delegator, pathSegment, matchOptions, moment);
// NOTE: currently, only storing in cache if has match...
// this is tradeoff of memory vs misses (risky to allow empty due to incoming from public)
if (!results.isEmpty()) {
productPathSegmentEntitiesCache.put(key, results);
}
}
// We now do this within the cache completely, which uses more memory for multi-store setup but is faster
//return results.filterResults(matchOptions);
return results;
}
/**
* SCIPIO: Tries to match an alt URL path element to a product.
* Heavily modified logic from CatalogUrlFilter.
* <p>
* Added 2017-11-08.
*/
public PathSegmentEntities matchPathSegmentProduct(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment) throws GenericEntityException {
Map<String, PathSegmentEntity> results = new LinkedHashMap<>();
matchPathSegmentProductImpl(delegator, pathSegment, matchOptions, moment, results);
return new PathSegmentEntities(CatalogUrlType.PRODUCT, pathSegment, optimizePathSegmentEntitiesReadOnly(results.values()), moment);
}
/**
* Extracts product ID from alt URL path element using any means applicable (core implementation/override), into the passed results map.
* Returns non-null (only) if an exact match was found, which is also added to the passed results map.
*/
protected void matchPathSegmentProductImpl(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
if (matchOptions.isAllowName()) {
matchPathSegmentProductByAltUrl(delegator, pathSegment, matchOptions, moment, results);
}
if (matchOptions.isAllowIdOnly()) {
matchPathSegmentProductById(delegator, pathSegment, matchOptions, moment, results);
}
}
/**
* Extracts product ID from alt URL path element, treating it as an Alternative URL (legacy definition), into the passed results map.
* Returns non-null only if an exact match was found, which is also added to the passed results map.
*/
protected void matchPathSegmentProductByAltUrl(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
// SCIPIO: this is a new filter that narrows down results from DB, which otherwise may be huge.
EntityCondition matchTextIdCond = makeAltUrlTextIdMatchCombinations(pathSegment, "productId", "textData", matchOptions.isRequireId());
// Search for non-localized alt urls
List<GenericValue> productContentInfos = findProductContentAltUrlValues(delegator, matchTextIdCond, moment, true);
matchPathSegmentAltUrl(delegator, pathSegment, productContentInfos, "productId", CatalogUrlType.PRODUCT, matchOptions, moment, results);
// Search for localized alt urls
List<GenericValue> productContentAssocInfos = findProductContentAltUrlLocalizedValues(delegator, matchTextIdCond, moment, true);
matchPathSegmentAltUrl(delegator, pathSegment, productContentAssocInfos, "productId", CatalogUrlType.PRODUCT, matchOptions, moment, results);
}
public static List<GenericValue> findProductContentAltUrlValues(Delegator delegator, EntityCondition filterCondition, Timestamp moment, boolean minimalSelect) throws GenericEntityException {
List<GenericValue> values = delegator.query().from("ProductContentAndElecTextShort")
.where(productContentTypeIdAltUrlCond, filterCondition)
.select(minimalSelect ? productContentAndElecTextShortMinimalSelectFields : null)
.filterByDate(moment) // NOTE: When cache==true, this is applied by EntityQuery in-memory after the DB query (important here)
.orderBy("-fromDate").cache(true).queryList();
return filterProductContentAltUrlValuesByDate(delegator, values, moment);
}
public static List<GenericValue> filterProductContentAltUrlValuesByDate(Delegator delegator, List<GenericValue> values, Timestamp moment) throws GenericEntityException {
if (moment == null) {
return values;
}
return EntityUtil.filterByDate(values, moment, "fromDate", "thruDate", true);
}
public static List<GenericValue> findProductContentAltUrlLocalizedValues(Delegator delegator, EntityCondition filterCondition, Timestamp moment, boolean minimalSelect) throws GenericEntityException {
List<GenericValue> values = EntityQuery.use(delegator).from("ProductContentAssocAndElecTextShort")
.where(productContentTypeIdAltUrlCond, contentAssocTypeIdAltLocaleCond, filterCondition)
.select(minimalSelect ? productContentAssocAndElecTextShortMinimalSelectFields : null)
.orderBy("-fromDate", "-caFromDate").cache(true).queryList();
return filterProductContentAltUrlLocalizedValuesByDate(delegator, values, moment);
}
public static List<GenericValue> filterProductContentAltUrlLocalizedValuesByDate(Delegator delegator, List<GenericValue> values, Timestamp moment) throws GenericEntityException {
if (moment == null) {
return values;
}
return EntityUtil.filterByCondition(values,
EntityCondition.makeCondition(
EntityUtil.getFilterByDateExpr(moment, "fromDate", "thruDate"),
EntityUtil.getFilterByDateExpr(moment, "caFromDate", "caThruDate")
));
}
/**
* Extracts product ID from alt URL path element, treating it as product ID only, into the passed results map.
* Returns non-null only if an exact match was found, which is also added to the passed results map.
* NOTE: This will skip returning a match if the results map already contains an exact match, but will replace a previous non-exact match.
*/
protected void matchPathSegmentProductById(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
// TODO: REVIEW: Do not use entity cache here: we already have UtilCaches and too many cache misses is not good
boolean useCache = false;
GenericValue product = EntityQuery.use(delegator).from("Product").where("productId", pathSegment).cache(useCache).queryOne();
if (product != null) {
String productId = product.getString("productId");
// this case has higher prio over non-exact match, but lower prio than alt url exact match
PathSegmentEntity prevMatch = results.get(productId);
if (prevMatch == null || !prevMatch.getBestMatchType().hasId()) {
addPathSegmentEntity(results, delegator, CatalogUrlType.PRODUCT, productId, pathSegment, matchOptions, moment, PathSegmentMatchType.ID, null, null, product);
}
}
}
/**
* SCIPIO: Tries to match an alt URL path element to a category and caches the results IF they match.
* Heavily modified logic from CatalogUrlFilter.
*/
public PathSegmentEntities matchPathSegmentCategoryCached(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment) throws GenericEntityException {
String key = delegator.getDelegatorName() + "::" + pathSegment + "::" + matchOptions.getCacheKey();
PathSegmentEntities results = categoryPathSegmentEntitiesCache.get(key);
if (results == null) {
results = matchPathSegmentCategory(delegator, pathSegment, matchOptions, moment);
// NOTE: currently, only storing in cache if has match...
// this is tradeoff of memory vs misses (risky to allow empty due to incoming from public)
if (!results.isEmpty()) {
categoryPathSegmentEntitiesCache.put(key, results);
}
}
// We now do this within the cache completely, which uses more memory for multi-store setup but is faster
//return results.filterResults(matchOptions);
return results;
}
/**
* SCIPIO: Tries to match an alt URL path element to a category and caches the results IF they match.
* Heavily modified logic from CatalogUrlFilter.
*/
public List<PathSegmentEntities> matchPathSegmentCategoriesCached(Delegator delegator, Collection<String> pathSegments, PathSegmentMatchOptions matchOptions, Timestamp moment) throws GenericEntityException {
List<PathSegmentEntities> result = new ArrayList<>();
for(String pathSegment : pathSegments) {
result.add(matchPathSegmentCategoryCached(delegator, pathSegment, matchOptions, moment));
}
return result;
}
/**
* SCIPIO: Tries to match an alt URL path element to a category.
* Heavily modified logic from CatalogUrlFilter.
* <p>
* Added 2017-11-07.
*/
public PathSegmentEntities matchPathSegmentCategory(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment) throws GenericEntityException {
Map<String, PathSegmentEntity> results = new LinkedHashMap<>();
matchPathSegmentCategoryImpl(delegator, pathSegment, matchOptions, moment, results);
return new PathSegmentEntities(CatalogUrlType.CATEGORY, pathSegment, optimizePathSegmentEntitiesReadOnly(results.values()), moment);
}
/**
* Extracts category ID from alt URL path element using any means applicable (core implementation/override), into the passed results map.
* Returns non-null only if an exact match was found, which is also added to the passed results map.
*/
protected void matchPathSegmentCategoryImpl(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
if (matchOptions.isAllowName()) {
matchPathSegmentCategoryByAltUrl(delegator, pathSegment, matchOptions, moment, results);
}
if (matchOptions.isAllowIdOnly()) {
matchPathSegmentCategoryById(delegator, pathSegment, matchOptions, moment, results);
}
}
/**
* Extracts category ID from alt URL path element, treating it as an Alternative URL (legacy definition), into the passed results map.
* Returns non-null only if an exact match was found, which is also added to the passed results map.
*/
protected void matchPathSegmentCategoryByAltUrl(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
// SCIPIO: this is a new filter that narrows down results from DB, which otherwise may be huge.
EntityCondition matchTextIdCond = makeAltUrlTextIdMatchCombinations(pathSegment, "productCategoryId", "textData", matchOptions.isRequireId());
// Search for non-localized alt urls
List<GenericValue> productCategoryContentInfos = findProductCategoryContentAltUrlValues(delegator, matchTextIdCond, moment, true);
matchPathSegmentAltUrl(delegator, pathSegment, productCategoryContentInfos, "productCategoryId", CatalogUrlType.CATEGORY, matchOptions, moment, results);
// Search for localized alt urls
List<GenericValue> productCategoryContentAssocInfos = findProductCategoryContentAltUrlLocalizedValues(delegator, matchTextIdCond, moment, true);
matchPathSegmentAltUrl(delegator, pathSegment, productCategoryContentAssocInfos, "productCategoryId", CatalogUrlType.CATEGORY, matchOptions, moment, results);
}
public static List<GenericValue> findProductCategoryContentAltUrlValues(Delegator delegator, EntityCondition filterCondition, Timestamp moment, boolean minimalSelect) throws GenericEntityException {
List<GenericValue> values = delegator.query().from("ProductCategoryContentAndElecTextShort")
.where(prodCatContentTypeIdAltUrlCond, filterCondition)
.select(minimalSelect ? productCategoryContentAndElecTextShortMinimalSelectFields : null)
.orderBy("-fromDate").cache(true).queryList();
return filterProductCategoryContentAltUrlValuesByDate(delegator, values, moment);
}
public static List<GenericValue> filterProductCategoryContentAltUrlValuesByDate(Delegator delegator, List<GenericValue> values, Timestamp moment) throws GenericEntityException {
if (moment == null) {
return values;
}
return EntityUtil.filterByDate(values, moment, "fromDate", "thruDate", true);
}
public static List<GenericValue> findProductCategoryContentAltUrlLocalizedValues(Delegator delegator, EntityCondition filterCondition, Timestamp moment, boolean minimalSelect) throws GenericEntityException {
List<GenericValue> values = delegator.query().from("ProductCategoryContentAssocAndElecTextShort")
.where(prodCatContentTypeIdAltUrlCond, contentAssocTypeIdAltLocaleCond, filterCondition)
.select(minimalSelect ? productCategoryContentAssocAndElecTextShortMinimalSelectFields : null)
.orderBy("-fromDate", "-caFromDate").cache(true).queryList();
return filterProductCategoryContentAltUrlLocalizedValuesByDate(delegator, values, moment);
}
public static List<GenericValue> filterProductCategoryContentAltUrlLocalizedValuesByDate(Delegator delegator, List<GenericValue> values, Timestamp moment) throws GenericEntityException {
if (moment == null) {
return values;
}
return EntityUtil.filterByCondition(values,
EntityCondition.makeCondition(
EntityUtil.getFilterByDateExpr(moment, "fromDate", "thruDate"),
EntityUtil.getFilterByDateExpr(moment, "caFromDate", "caThruDate")
));
}
/**
* Extracts category ID from alt URL path element, treating it as category ID only, into the passed results map.
* Returns non-null only if an exact match was found, which is also added to the passed results map.
* NOTE: This will skip returning a match if the results map already contains an exact match, but will replace a previous non-exact match.
*/
protected void matchPathSegmentCategoryById(Delegator delegator, String pathSegment, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) throws GenericEntityException {
// TODO: REVIEW: Do not use entity cache here: we already have UtilCaches and too many cache misses is not good
boolean useCache = false;
GenericValue productCategory = delegator.query().from("ProductCategory").where("productCategoryId", pathSegment).cache(useCache).queryOne();
if (productCategory != null) {
String productCategoryId = productCategory.getString("productCategoryId");
// this case has higher prio over non-exact match, but lower prio than alt url exact match
PathSegmentEntity prevMatch = results.get(productCategoryId);
if (prevMatch == null || !prevMatch.getBestMatchType().hasId()) {
addPathSegmentEntity(results, delegator, CatalogUrlType.CATEGORY, productCategoryId, pathSegment, matchOptions, moment, PathSegmentMatchType.ID, null, null, productCategory);
}
}
}
/**
* This splits pathSegment by hyphen "-" and creates OR condition for all the possible combinations
* of name and ID.
*/
public static EntityCondition makeAltUrlTextIdMatchCombinations(String pathSegment, String idField, String textField, boolean exactOnly) {
List<EntityCondition> condList = new ArrayList<>();
int lastIndex = pathSegment.lastIndexOf('-');
while (lastIndex > 0) {
String name = pathSegment.substring(0, lastIndex);
String id = pathSegment.substring(lastIndex + 1);
if (!id.isEmpty()) { // 2019-08-09: if ID is empty, we have a URL that ends with a dash ('-'); covered for backward-compat below
condList.add(EntityCondition.makeCondition(
EntityCondition.makeCondition(textField, EntityOperator.LIKE, name),
EntityOperator.AND,
EntityCondition.makeCondition(idField, id)));
}
lastIndex = pathSegment.lastIndexOf('-', lastIndex - 1);
}
if (!exactOnly) {
// try one without any ID
condList.add(EntityCondition.makeCondition(textField, EntityOperator.LIKE, pathSegment));
}
return EntityCondition.makeCondition(condList, EntityOperator.OR);
}
/**
* Finds ALTERNATIVE_URL matches and adds to results map. If an exact match is found, returns immediately
* with the result. If no exact, returns null.
* Based on CatalogUrlFilter iteration.
* <p>
* NOTE: 2017: the singleExactOnly flag was initially going to be important for performance reasons,
* but it can now be left to false (which is 0.01% safer) as long as the caller uses
* {@link #makeAltUrlTextIdMatchCombinations} in the values query.
*/
private void matchPathSegmentAltUrl(Delegator delegator, String pathSegment, List<GenericValue> values, String idField,
CatalogUrlType entityType, PathSegmentMatchOptions matchOptions, Timestamp moment, Map<String, PathSegmentEntity> results) {
for (GenericValue value : values) {
String textData = value.getString("textData");
// TODO: REVIEW: Do nothing here because if we don't have to sanitize the DB records, it could
// allow some types of LIKE DB queries and allow for optimizations, so try to do without sanitize
//getCatalogAltUrlSanitizer().sanitizeAltUrlFromDb(textData, null, entityType);
//textData = UrlServletHelper.invalidCharacter(textData);
if (pathSegment.startsWith(textData)) {
String pathSegmentIdStr = pathSegment.substring(textData.length());
String valueId = value.getString(idField);
if (pathSegmentIdStr.isEmpty()) {
if (!matchOptions.isRequireId()) {
addPathSegmentEntity(results, delegator, entityType, valueId, pathSegment, matchOptions, moment, PathSegmentMatchType.NAME, textData, value.getString("localeString"), value);
}
} else {
if (pathSegmentIdStr.startsWith("-")) { // should always be a hyphen here
pathSegmentIdStr = pathSegmentIdStr.substring(1);
if (pathSegmentIdStr.equalsIgnoreCase(valueId)) { // TODO: Case-ignore behavior here should be a configurable option
addPathSegmentEntity(results, delegator, entityType, valueId, pathSegment, matchOptions, moment, PathSegmentMatchType.NAME_ID, textData, value.getString("localeString"), value);
}
}
}
}
}
}
/*
* *****************************************************
* Outbound matching
* *****************************************************
*/
private static final Pattern absUrlPat = Pattern.compile("(((.*?):)?//([^/]*))?(.*)");
/**
* Checks an outbound URL for /control/product, /control/category or other
* such request and tries to extract the IDs.
* FIXME: does not preserve trail, uses default always. Also didn't fully abstract this yet.
* FIXME: cannot handle jsessionid
*/
public String matchReplaceOutboundSeoTranslatableUrl(HttpServletRequest request, Delegator delegator, String url, String productReqPath, String categoryReqPath, String contextRoot) {
if (url == null) return null;
// pre-filters for speed
if (url.contains(productReqPath) || url.contains(categoryReqPath)) {
// Extract the relative URL from absolute
Matcher mrel = absUrlPat.matcher(url);
if (mrel.matches()) {
String absPrefix = mrel.group(1);
if (absPrefix == null) {
absPrefix = "";
}
String relUrl = mrel.group(5);
// Check if within same webapp
// (Note: in Ofbiz the encoded URLs contain the webapp context root in relative URLs)
if (relUrl.startsWith((contextRoot.length() > 1) ? contextRoot + "/" : contextRoot)) {
String pathInfo = relUrl.substring(contextRoot.length());
if (pathInfo.startsWith(productReqPath)) {
if (pathInfo.length() > productReqPath.length()) {
Map<String, String> params = extractParamsFromRest(pathInfo, productReqPath.length());
if (params == null) return null;
String productId = params.remove("product_id");
if (productId == null) {
productId = params.remove("productId");
}
if (productId != null) {
String colonString = params.remove("colonString");
if (colonString == null) colonString = "";
Locale locale = UtilHttp.getLocale(request); // FIXME?: sub-ideal
String newUrl = makeProductUrl(request, locale, null, null, productId);
if (newUrl == null) return null;
String remainingParams = makeRemainingParamsStr(params, pathInfo.contains("&"), !newUrl.contains("?"));
return absPrefix + newUrl + colonString + remainingParams;
}
}
} else if (pathInfo.startsWith(categoryReqPath)) {
if (pathInfo.length() > categoryReqPath.length()) {
Map<String, String> params = extractParamsFromRest(pathInfo, categoryReqPath.length());
if (params == null) return null;
String categoryId = params.remove("category_id");
if (categoryId == null) {
categoryId = params.remove("productCategoryId");
}
if (categoryId != null) {
String colonString = params.remove("colonString");
if (colonString == null) colonString = "";
Locale locale = UtilHttp.getLocale(request); // FIXME?: sub-ideal
// FIXME miss view size
String newUrl = makeCategoryUrl(request, locale, null, categoryId, null, null, null, null, null);
if (newUrl == null) return null;
String remainingParams = makeRemainingParamsStr(params, pathInfo.contains("&"), !newUrl.contains("?"));
return absPrefix + newUrl + colonString + remainingParams;
}
}
}
}
}
}
return null;
}
private static final Pattern paramPat = Pattern.compile("&|&");
private static Map<String, String> extractParamsFromRest(String pathInfo, int index) {
char nextChar = pathInfo.charAt(index);
String queryString;
// String colonString;
if (nextChar == '?') {
// FIXME: can't handle colon parameters
// colonString = null;
queryString = pathInfo.substring(index + 1);
// int colonIndex = pathInfo.indexOf(';', index + 1);
// if (colonIndex >= 0) {
// colonString = pathInfo.substring(colonIndex);
// queryString = pathInfo.substring(index + 1, colonIndex);
// } else {
// queryString = pathInfo.substring(index + 1);
// colonString = null;
// }
// } else if (nextChar == ';') {
// int colonIndex = nextChar;
// index = pathInfo.indexOf('?', index+1);
// if (index < 0) return new HashMap<>();
// colonString = pathInfo.substring(colonIndex, index);
// queryString = pathInfo.substring(index + 1);
} else {
return null;
}
Map<String, String> params = extractParamsFromQueryString(queryString);
// if (colonString != null) params.put("colonString", colonString);
return params;
}
private static Map<String, String> extractParamsFromQueryString(String queryString) {
String[] parts = paramPat.split(queryString);
Map<String, String> params = new LinkedHashMap<>();
for(String part : parts) {
if (part.length() == 0) continue;
int equalsIndex = part.indexOf('=');
if (equalsIndex <= 0 || equalsIndex >= (part.length() - 1)) continue;
params.put(part.substring(0, equalsIndex), part.substring(equalsIndex + 1));
}
return params;
}
private static String makeRemainingParamsStr(Map<String, String> params, boolean useEncoded, boolean firstParams) {
StringBuilder sb = new StringBuilder();
for(Map.Entry<String, String> entry : params.entrySet()) {
if (sb.length() == 0 && firstParams) {
sb.append("?");
} else {
sb.append(useEncoded ? "&" : "&");
}
sb.append(entry.getKey());
sb.append("=");
sb.append(entry.getValue());
}
return sb.toString();
}
/*
* *****************************************************
* Abstracted general queries for product/category URLs
* *****************************************************
* NOTE: some of these may be cacheable if needed in future.
*/
/**
* Returns the top categories that are valid for use when generating category URLs,
* as ORDERED set.
* <p>
* 2017: NOTE: for legacy behavior, this returns all ProdCatalogCategory IDs for category URLs (only).
*/
protected Set<String> getCatalogTopCategoriesForCategoryUrl(Delegator delegator, String currentCatalogId, String webSiteId) {
if (currentCatalogId == null) {
currentCatalogId = getCurrentCatalogId(delegator, currentCatalogId, webSiteId);
if (currentCatalogId == null) return new LinkedHashSet<>();
}
return getProdCatalogCategoryIds(delegator, currentCatalogId, null);
}
/**
* Returns the top categories that are valid for use when generating product URLs,
* as ORDERED set.
* <p>
* 2017: NOTE: for legacy behavior, this currently returns only the single first top catalog category.
* <p>
* TODO: REVIEW: this could support more, but it may affect store browsing.
*/
protected Set<String> getCatalogTopCategoriesForProductUrl(Delegator delegator, String currentCatalogId, String webSiteId) {
if (currentCatalogId == null) {
currentCatalogId = getCurrentCatalogId(delegator, currentCatalogId, webSiteId);
if (currentCatalogId == null) return new LinkedHashSet<>();
}
return getProdCatalogTopCategoryId(delegator, currentCatalogId);
}
/**
* Return all paths from the given topCategoryIds to the product.
* <p>
* TODO?: perhaps can cache with UtilCache in future, or read from a cached category tree.
*/
protected List<List<String>> getProductRollupTrails(Delegator delegator, String productId, Set<String> topCategoryIds, boolean useCache) {
return ProductWorker.getProductRollupTrails(delegator, productId, topCategoryIds, useCache);
}
/**
* Return all paths from the given topCategoryIds to the product.
* <p>
* TODO?: perhaps can cache with UtilCache in future, or read from a cached category tree.
*/
protected List<List<String>> getProductRollupTrails(Delegator delegator, String productId, Set<String> topCategoryIds) {
return getProductRollupTrails(delegator, productId, topCategoryIds, true);
}
/**
* Return all paths from the given topCategoryIds to the category.
* <p>
* TODO?: perhaps can cache with UtilCache in future, or read from a cached category tree.
*/
protected List<List<String>> getCategoryRollupTrails(Delegator delegator, String productCategoryId, Set<String> topCategoryIds, boolean useCache) {
return CategoryWorker.getCategoryRollupTrails(delegator, productCategoryId, topCategoryIds, useCache);
}
/**
* Return all paths from the given topCategoryIds to the category.
* <p>
* TODO?: perhaps can cache with UtilCache in future, or read from a cached category tree.
*/
protected List<List<String>> getCategoryRollupTrails(Delegator delegator, String productCategoryId, Set<String> topCategoryIds) {
return getCategoryRollupTrails(delegator, productCategoryId, topCategoryIds, true);
}
/*
* *****************************************************
* Generic/static helpers
* *****************************************************
*/
/**
* Returns all ProdCatalogCategory IDs or the types requested (null for all).
*/
protected static Set<String> getProdCatalogCategoryIds(Delegator delegator, String prodCatalogId, Collection<String> prodCatalogCategoryTypeIds) {
if (prodCatalogId == null || prodCatalogId.isEmpty()) return new LinkedHashSet<>();
List<GenericValue> values = CatalogWorker.getProdCatalogCategories(delegator, prodCatalogId, null);
Set<String> idList = new LinkedHashSet<>();
for(GenericValue value : values) {
if (prodCatalogCategoryTypeIds == null || prodCatalogCategoryTypeIds.contains(value.getString("prodCatalogCategoryTypeId"))) {
idList.add(value.getString("productCategoryId"));
}
}
return idList;
}
/**
* Returns the single top category.
*/
protected static Set<String> getProdCatalogTopCategoryId(Delegator delegator, String prodCatalogId) {
String topCategoryId = CatalogWorker.getCatalogTopCategoryId(delegator, prodCatalogId);
Set<String> topCategoryIds = new LinkedHashSet<>();
if (topCategoryId == null) {
Debug.logWarning("Seo: matchInboundSeoCatalogUrl: cannot determine top category for prodCatalogId '" + prodCatalogId + "'; can't select best trail", module);
} else {
topCategoryIds.add(topCategoryId);
}
return topCategoryIds;
}
protected static String getCurrentCatalogId(Delegator delegator, String currentCatalogId, String webSiteId) {
if (currentCatalogId == null) {
currentCatalogId = getWebsiteTopCatalog(delegator, webSiteId);
if (currentCatalogId == null) {
Debug.logWarning("Seo: Cannot determine current or top catalog for webSiteId: " + webSiteId, module);
}
}
return currentCatalogId;
}
protected static String getWebsiteTopCatalog(Delegator delegator, String webSiteId) {
if (UtilValidate.isEmpty(webSiteId)) {
return null;
}
try {
GenericValue webSite = EntityQuery.use(delegator).from("WebSite").where("webSiteId", webSiteId).cache(true).queryOne();
if (webSite == null) {
Debug.logError("getWebsiteTopCatalog: Invalid webSiteId: " + webSiteId, module);
return null;
}
String productStoreId = webSite.getString("productStoreId");
if (productStoreId == null) return null;
List<GenericValue> storeCatalogs = CatalogWorker.getStoreCatalogs(delegator, productStoreId);
if (UtilValidate.isNotEmpty(storeCatalogs)) {
return storeCatalogs.get(0).getString("prodCatalogId");
}
} catch(Exception e) {
Debug.logError("getWebsiteTopCatalog: error while determining catalog for webSiteId '" + webSiteId + "': " + e.getMessage(), module);
return null;
}
return null;
}
protected static List<String> newPathList() {
return new ArrayList<>();
}
protected static List<String> newPathList(int initialCapacity) {
return new ArrayList<>(initialCapacity);
}
protected static List<String> newPathList(Collection<String> fromList) {
return new ArrayList<>(fromList);
}
protected static List<String> ensurePathList(List<String> pathList) {
return pathList != null ? pathList : newPathList();
}
/**
* Returns the first trail having the topCategory which is the earliest possible in the topCategoryIds list,
* or null if none of them.
* Prefers lower ProdCatalogCategory sequenceNum.
*/
protected static List<String> getFirstTopTrail(List<List<String>> possibleTrails, Collection<String> topCategoryIds) {
for(String topCategoryId : topCategoryIds) { // usually just one iteration
for(List<String> trail : possibleTrails) {
if (trail != null && !trail.isEmpty() && topCategoryId.equals(trail.get(0))) return trail;
}
}
return null;
}
// NOTE: if need this, there's should already be a util somewhere...
// private static boolean pathStartsWithDir(String path, String dir) {
// // needs delimiter logic
// if (UtilValidate.isEmpty(path)) {
// return UtilValidate.isEmpty(dir);
// }
// if (path.length() > dir.length()) {
// if (dir.endsWith("/")) return path.startsWith(dir);
// else return path.startsWith(dir + "/");
// } else {
// return path.equals(dir);
// }
// }
private static <T> void removeLastIfEquals(List<T> list, T value) {
if (list != null && list.size() > 0 && value != null && value.equals(list.get(list.size() - 1))) {
list.remove(list.size() - 1);
}
}
private static <T> void removeLastIfSameRef(List<T> list, T value) {
if (list != null && list.size() > 0 && value != null && value == list.get(list.size() - 1)) {
list.remove(list.size() - 1);
}
}
/**
* Last index of, with starting index (inclusive - .get(startIndex) is compared - like String interface).
*/
static <T> int lastIndexOf(List<T> list, T object, int startIndex) {
ListIterator<T> it = list.listIterator(startIndex + 1);
while(it.hasPrevious()) {
if (object.equals(it.previous())) {
return it.nextIndex();
}
}
return -1;
}
static void ensureTrailingSlash(StringBuilder sb) {
if (sb.length() == 0 || sb.charAt(sb.length() - 1) != '/') {
sb.append("/");
}
}
static void appendSlashAndValue(StringBuilder sb, String value) {
if (sb.length() == 0 || sb.charAt(sb.length() - 1) != '/') {
sb.append("/");
}
sb.append(value);
}
static void appendSlashAndValue(StringBuilder sb, Collection<String> values) {
for(String value : values) {
if (sb.length() == 0 || sb.charAt(sb.length() - 1) != '/') {
sb.append("/");
}
sb.append(value);
}
}
/**
* Maps locale to name and vice-versa - optimization to avoid ResourceBundle, which
* has API limitations and needless lookups.
* <p>
* FIXME?: This has issues and might not 100% emulate the properties map behavior yet.
*/
static class LocalizedName implements Serializable {
private final String defaultValue;
private final Map<String, String> localeValueMap;
private final Map<String, Locale> valueLocaleMap;
public LocalizedName(Map<String, String> localeValueMap, Locale defaultLocale) {
this.localeValueMap = new HashMap<>(localeValueMap);
Map<String, Locale> valueLocaleMap = makeValueLocaleMap(localeValueMap);
String defaultValue = (defaultLocale != null) ? getNameForLocale(localeValueMap, defaultLocale) : null;
if (UtilValidate.isNotEmpty(defaultValue)) {
// 2019-08-09: re-insert the default value for default locale because otherwise it may register to another language
valueLocaleMap.put(defaultValue, defaultLocale);
}
this.valueLocaleMap = valueLocaleMap;
this.defaultValue = defaultValue;
}
/**
* NOTE: the resulting mapping may not be 1-for-1 - depends on the values.
*/
private static Map<String, Locale> makeValueLocaleMap(Map<String, String> localeValueMap) {
Map<String, Locale> valueLocaleMap = new HashMap<>();
for(Map.Entry<String, String> entry : localeValueMap.entrySet()) {
// SPECIAL: duplicates *could* be possible - keep the shortest locale name (most generic)
// for the string-to-locale mapping
Locale prevLocale = valueLocaleMap.get(entry.getValue());
Locale nextLocale = UtilMisc.parseLocale(entry.getKey());
if (prevLocale != null) {
if (nextLocale.toString().length() < prevLocale.toString().length()) {
valueLocaleMap.put(entry.getValue(), nextLocale);
}
} else {
valueLocaleMap.put(entry.getValue(), nextLocale);
}
}
return valueLocaleMap;
}
/**
* Tries to create an instance from a property message.
* <p>
* FIXME?: there must be a better way than current loop with exception on missing locale...
*/
public static LocalizedName getNormalizedFromProperties(String resource, String name, Locale defaultLocale) {
Map<String, String> localeValueMap = new HashMap<>();
for(Locale locale: UtilMisc.availableLocales()) {
ResourceBundle bundle = null;
try {
bundle = UtilProperties.getResourceBundle(resource, locale);
} catch(Exception e) {
continue; // when locale is not there, throws IllegalArgumentException
}
if (bundle == null || !bundle.containsKey(name)) continue;
String value = bundle.getString(name);
if (value == null) continue;
value = value.trim();
if (value.isEmpty()) continue;
localeValueMap.put(locale.toString(), value);
}
return new LocalizedName(localeValueMap, defaultLocale);
}
/**
* Tries to create an instance from a property message, with general.properties fallback
* locale as the default locale.
*/
public static LocalizedName getNormalizedFromProperties(String resource, String name) {
return getNormalizedFromProperties(resource, name, UtilProperties.getFallbackLocale());
}
public Locale getLocaleForName(String name) {
return valueLocaleMap.get(name);
}
public String getNameForLocale(Locale locale) {
return getNameForLocale(localeValueMap, locale);
}
private static String getNameForLocale(Map<String, String> localeValueMap, Locale locale) {
String value = localeValueMap.get(locale.toString());
if (value == null) value = localeValueMap.get(normalizeLocaleStr(locale));
return value;
}
public String getNameForLocaleOrDefault(Locale locale) {
if (locale == null) return defaultValue;
String value = getNameForLocale(locale);
if (value == null) value = defaultValue;
return value;
}
public static String normalizeLocaleStr(Locale locale) {
return locale.getLanguage();
}
}
}