View Javadoc
1   /*
2    * Copyright 2012-2025 CodeLibs Project and the Others.
3    *
4    * Licensed under the Apache License, Version 2.0 (the "License");
5    * you may not use this file except in compliance with the License.
6    * You may obtain a copy of the License at
7    *
8    *     http://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS,
12   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
13   * either express or implied. See the License for the specific language
14   * governing permissions and limitations under the License.
15   */
16  package org.codelibs.fess.helper;
17  
18  import java.util.ArrayList;
19  import java.util.Collections;
20  import java.util.HashMap;
21  import java.util.List;
22  import java.util.Locale;
23  import java.util.Map;
24  import java.util.regex.Pattern;
25  
26  import org.apache.logging.log4j.LogManager;
27  import org.apache.logging.log4j.Logger;
28  import org.codelibs.core.lang.StringUtil;
29  import org.codelibs.core.misc.Pair;
30  import org.codelibs.fess.opensearch.config.exbhv.RelatedContentBhv;
31  import org.codelibs.fess.opensearch.config.exentity.RelatedContent;
32  import org.codelibs.fess.util.ComponentUtil;
33  
34  import jakarta.annotation.PostConstruct;
35  
36  /**
37   * Helper class for managing related content configurations.
38   * This class provides functionality to load, cache, and retrieve related content
39   * based on search queries and virtual host configurations. It supports both exact
40   * term matching and regex pattern matching for flexible content association.
41   */
42  public class RelatedContentHelper extends AbstractConfigHelper {
43  
44      /**
45       * Default constructor for RelatedContentHelper.
46       * The constructor does not perform any initialization logic as the actual
47       * initialization is handled by the {@link #init()} method annotated with
48       * {@code @PostConstruct}.
49       */
50      public RelatedContentHelper() {
51          super();
52      }
53  
54      private static final Logger logger = LogManager.getLogger(RelatedContentHelper.class);
55  
56      /**
57       * Cache map storing related content configurations organized by virtual host key.
58       * The outer map key is the virtual host key, and the value is a pair containing:
59       * - First: Map of exact term matches (term -> content)
60       * - Second: List of regex pattern matches (Pattern -> content template)
61       */
62      protected Map<String, Pair<Map<String, String>, List<Pair<Pattern, String>>>> relatedContentMap = Collections.emptyMap();
63  
64      /**
65       * Prefix used to identify regex patterns in related content terms.
66       * When a term starts with this prefix, it is treated as a regular expression
67       * pattern rather than an exact match term.
68       */
69      protected String regexPrefix = "regex:";
70  
71      /**
72       * Placeholder string used in regex-based related content templates.
73       * This placeholder is replaced with the actual search query when
74       * a regex pattern matches the query.
75       */
76      protected String queryPlaceHolder = "__QUERY__";
77  
78      /**
79       * Initializes the RelatedContentHelper by loading related content configurations
80       * from the data store. This method is called automatically after dependency
81       * injection is complete.
82       *
83       * PostConstruct annotation ensures this method is called after the bean
84       * has been constructed and all dependencies have been injected.
85       */
86      @PostConstruct
87      public void init() {
88          if (logger.isDebugEnabled()) {
89              logger.debug("Initializing {}", this.getClass().getSimpleName());
90          }
91          load();
92      }
93  
94      /**
95       * Retrieves all available related content configurations from the data store.
96       * The results are ordered by sort order ascending, then by term ascending.
97       * The number of results is limited by the configured maximum fetch size.
98       *
99       * @return List of RelatedContent entities containing all available related content configurations
100      */
101     public List<RelatedContent> getAvailableRelatedContentList() {
102         return ComponentUtil.getComponent(RelatedContentBhv.class).selectList(cb -> {
103             cb.query().matchAll();
104             cb.query().addOrderBy_SortOrder_Asc();
105             cb.query().addOrderBy_Term_Asc();
106             cb.fetchFirst(ComponentUtil.getFessConfig().getPageRelatedcontentMaxFetchSizeAsInteger());
107         });
108     }
109 
110     @Override
111     public int load() {
112         final Map<String, Pair<Map<String, String>, List<Pair<Pattern, String>>>> relatedContentMap = new HashMap<>();
113         getAvailableRelatedContentList().stream().forEach(entity -> {
114             final String key = getHostKey(entity);
115             Pair<Map<String, String>, List<Pair<Pattern, String>>> pair = relatedContentMap.get(key);
116             if (pair == null) {
117                 pair = new Pair<>(new HashMap<>(), new ArrayList<>());
118                 relatedContentMap.put(key, pair);
119             }
120             if (entity.getTerm().startsWith(regexPrefix)) {
121                 final String regex = entity.getTerm().substring(regexPrefix.length());
122                 if (StringUtil.isBlank(regex)) {
123                     logger.warn("Unknown regex pattern: {}", entity.getTerm());
124                 } else {
125                     pair.getSecond().add(new Pair<>(Pattern.compile(regex), entity.getContent()));
126                 }
127             } else {
128                 pair.getFirst().put(toLowerCase(entity.getTerm()), entity.getContent());
129             }
130         });
131         this.relatedContentMap = relatedContentMap;
132         return relatedContentMap.size();
133     }
134 
135     /**
136      * Extracts the virtual host key from a RelatedContent entity.
137      * If the virtual host is blank or null, returns an empty string.
138      * This key is used to organize related content by virtual host.
139      *
140      * @param entity the RelatedContent entity to extract the host key from
141      * @return the virtual host key, or empty string if not specified
142      */
143     protected String getHostKey(final RelatedContent entity) {
144         final String key = entity.getVirtualHost();
145         return StringUtil.isBlank(key) ? StringUtil.EMPTY : key;
146     }
147 
148     /**
149      * Retrieves related content for a given search query.
150      * First checks for exact term matches, then evaluates regex patterns.
151      * For regex matches, the query placeholder is replaced with the actual query.
152      *
153      * @param query the search query to find related content for
154      * @return array of related content strings, or empty array if no matches found
155      */
156     public String[] getRelatedContents(final String query) {
157         final String key = ComponentUtil.getVirtualHostHelper().getVirtualHostKey();
158         final Pair<Map<String, String>, List<Pair<Pattern, String>>> pair = relatedContentMap.get(key);
159         if (pair != null) {
160             final List<String> contentList = new ArrayList<>();
161             final String content = pair.getFirst().get(toLowerCase(query));
162             if (StringUtil.isNotBlank(content)) {
163                 contentList.add(content);
164             }
165             for (final Pair<Pattern, String> regexData : pair.getSecond()) {
166                 if (regexData.getFirst().matcher(query).matches()) {
167                     contentList.add(regexData.getSecond().replace(queryPlaceHolder, query));
168                 }
169             }
170             return contentList.toArray(new String[contentList.size()]);
171         }
172         return StringUtil.EMPTY_STRINGS;
173     }
174 
175     private String toLowerCase(final String term) {
176         return term != null ? term.toLowerCase(Locale.ROOT) : term;
177     }
178 
179     /**
180      * Sets the prefix used to identify regex patterns in related content terms.
181      * When a term starts with this prefix, it is treated as a regular expression
182      * pattern rather than an exact match term.
183      *
184      * @param regexPrefix the prefix string to identify regex patterns (default: "regex:")
185      */
186     public void setRegexPrefix(final String regexPrefix) {
187         this.regexPrefix = regexPrefix;
188     }
189 
190     /**
191      * Sets the placeholder string used in regex-based related content templates.
192      * This placeholder is replaced with the actual search query when
193      * a regex pattern matches the query.
194      *
195      * @param queryPlaceHolder the placeholder string to be replaced with the query (default: "__QUERY__")
196      */
197     public void setQueryPlaceHolder(final String queryPlaceHolder) {
198         this.queryPlaceHolder = queryPlaceHolder;
199     }
200 
201 }