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 }