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 static org.codelibs.core.stream.StreamUtil.split;
19  
20  import java.io.BufferedInputStream;
21  import java.io.File;
22  import java.io.IOException;
23  import java.io.InputStream;
24  import java.io.UnsupportedEncodingException;
25  import java.net.URLDecoder;
26  import java.net.URLEncoder;
27  import java.util.ArrayList;
28  import java.util.Date;
29  import java.util.HashMap;
30  import java.util.HashSet;
31  import java.util.List;
32  import java.util.Locale;
33  import java.util.Map;
34  import java.util.Set;
35  import java.util.concurrent.ConcurrentHashMap;
36  import java.util.concurrent.ExecutionException;
37  import java.util.concurrent.TimeUnit;
38  import java.util.function.Consumer;
39  import java.util.function.Function;
40  import java.util.regex.Matcher;
41  import java.util.regex.Pattern;
42  import java.util.stream.Collectors;
43  
44  import org.apache.catalina.connector.ClientAbortException;
45  import org.apache.commons.lang3.StringUtils;
46  import org.apache.commons.text.StringEscapeUtils;
47  import org.apache.logging.log4j.LogManager;
48  import org.apache.logging.log4j.Logger;
49  import org.codelibs.core.CoreLibConstants;
50  import org.codelibs.core.io.CloseableUtil;
51  import org.codelibs.core.lang.StringUtil;
52  import org.codelibs.core.misc.DynamicProperties;
53  import org.codelibs.core.stream.StreamUtil;
54  import org.codelibs.fess.Constants;
55  import org.codelibs.fess.app.web.base.SearchForm;
56  import org.codelibs.fess.app.web.base.login.FessLoginAssist;
57  import org.codelibs.fess.crawler.builder.RequestDataBuilder;
58  import org.codelibs.fess.crawler.client.CrawlerClient;
59  import org.codelibs.fess.crawler.client.CrawlerClientFactory;
60  import org.codelibs.fess.crawler.entity.ResponseData;
61  import org.codelibs.fess.crawler.util.CharUtil;
62  import org.codelibs.fess.entity.FacetQueryView;
63  import org.codelibs.fess.entity.HighlightInfo;
64  import org.codelibs.fess.entity.SearchRenderData;
65  import org.codelibs.fess.exception.FessSystemException;
66  import org.codelibs.fess.helper.UserAgentHelper.UserAgentType;
67  import org.codelibs.fess.mylasta.action.FessUserBean;
68  import org.codelibs.fess.mylasta.direction.FessConfig;
69  import org.codelibs.fess.opensearch.config.exentity.CrawlingConfig;
70  import org.codelibs.fess.util.ComponentUtil;
71  import org.codelibs.fess.util.DocumentUtil;
72  import org.codelibs.fess.util.FacetResponse;
73  import org.codelibs.fess.util.ResourceUtil;
74  import org.dbflute.optional.OptionalThing;
75  import org.lastaflute.taglib.function.LaFunctions;
76  import org.lastaflute.web.response.ActionResponse;
77  import org.lastaflute.web.response.StreamResponse;
78  import org.lastaflute.web.ruts.process.ActionRuntime;
79  import org.lastaflute.web.util.LaRequestUtil;
80  import org.lastaflute.web.util.LaServletContextUtil;
81  import org.opensearch.core.common.text.Text;
82  import org.opensearch.search.fetch.subphase.highlight.HighlightField;
83  
84  import com.github.jknack.handlebars.Context;
85  import com.github.jknack.handlebars.Handlebars;
86  import com.github.jknack.handlebars.Template;
87  import com.github.jknack.handlebars.io.FileTemplateLoader;
88  import com.google.common.cache.Cache;
89  import com.google.common.cache.CacheBuilder;
90  import com.ibm.icu.text.SimpleDateFormat;
91  
92  import jakarta.annotation.PostConstruct;
93  import jakarta.servlet.ServletContext;
94  import jakarta.servlet.SessionTrackingMode;
95  import jakarta.servlet.http.HttpServletRequest;
96  import jakarta.servlet.http.HttpSession;
97  
98  /**
99   * Helper class for handling view-related operations in the Fess search system.
100  * This class provides utilities for content rendering, URL processing, highlighting,
101  * caching, pagination, and user interface functionality.
102  *
103  */
104 public class ViewHelper {
105 
106     /**
107      * Default constructor for ViewHelper.
108      */
109     public ViewHelper() {
110         // Default constructor
111     }
112 
113     private static final Logger logger = LogManager.getLogger(ViewHelper.class);
114 
115     /** Request attribute key for screen width */
116     protected static final String SCREEN_WIDTH = "screen_width";
117 
118     /** Tablet width threshold for responsive design */
119     protected static final int TABLET_WIDTH = 768;
120 
121     /** HTTP header name for content disposition */
122     protected static final String CONTENT_DISPOSITION = "Content-Disposition";
123 
124     /** Cache key for highlighted cache content */
125     protected static final String HL_CACHE = "hl_cache";
126 
127     /** Cache key for search queries */
128     protected static final String QUERIES = "queries";
129 
130     /** Cache key for cache message */
131     protected static final String CACHE_MSG = "cache_msg";
132 
133     /** Pattern for matching local file paths */
134     protected static final Pattern LOCAL_PATH_PATTERN = Pattern.compile("^file:/+[a-zA-Z]:");
135 
136     /** Pattern for matching shared folder paths */
137     protected static final Pattern SHARED_FOLDER_PATTERN = Pattern.compile("^file:/+[^/]\\.");
138 
139     /** Ellipsis string for text truncation */
140     protected static final String ELLIPSIS = "...";
141 
142     /** Whether to encode URL links */
143     protected boolean encodeUrlLink = false;
144 
145     /** Character encoding for URL links */
146     protected String urlLinkEncoding = Constants.UTF_8;
147 
148     /** Fields that should be highlighted in search results */
149     protected String[] highlightedFields;
150 
151     /** Original highlight tag prefix */
152     protected String originalHighlightTagPre = "<em>";
153 
154     /** Original highlight tag suffix */
155     protected String originalHighlightTagPost = "</em>";
156 
157     /** Configured highlight tag prefix */
158     protected String highlightTagPre;
159 
160     /** Configured highlight tag suffix */
161     protected String highlightTagPost;
162 
163     /** Whether to use HTTP sessions */
164     protected boolean useSession = true;
165 
166     /** Cache for page paths */
167     protected final Map<String, String> pageCacheMap = new ConcurrentHashMap<>();
168 
169     /** Initial facet parameter mappings */
170     protected final Map<String, String> initFacetParamMap = new HashMap<>();
171 
172     /** Initial geographic parameter mappings */
173     protected final Map<String, String> initGeoParamMap = new HashMap<>();
174 
175     /** List of facet query views */
176     protected final List<FacetQueryView> facetQueryViewList = new ArrayList<>();
177 
178     /** Template name for cache content */
179     protected String cacheTemplateName = "cache";
180 
181     /** HTML-escaped highlight prefix */
182     protected String escapedHighlightPre = null;
183 
184     /** HTML-escaped highlight suffix */
185     protected String escapedHighlightPost = null;
186 
187     /** Set of terminal characters for highlighting */
188     protected Set<Integer> highlightTerminalCharSet = new HashSet<>();
189 
190     /** Action hook for custom processing */
191     protected ActionHook actionHook = new ActionHook();
192 
193     /** Set of MIME types that should be displayed inline */
194     protected final Set<String> inlineMimeTypeSet = new HashSet<>();
195 
196     /** Cache for facet responses */
197     protected Cache<String, FacetResponse> facetCache;
198 
199     /** Duration for facet cache in seconds (10 minutes) */
200     protected long facetCacheDuration = 60 * 10L;
201 
202     /** Length of text fragment prefix */
203     protected int textFragmentPrefixLength;
204 
205     /** Length of text fragment suffix */
206     protected int textFragmentSuffixLength;
207 
208     /** Size of text fragments */
209     protected int textFragmentSize;
210 
211     /**
212      * Initializes the ViewHelper with configuration settings.
213      * Sets up highlighting, caching, and other view-related configurations.
214      */
215     @PostConstruct
216     public void init() {
217         if (logger.isDebugEnabled()) {
218             logger.debug("Initializing {}", this.getClass().getSimpleName());
219         }
220         final FessConfig fessConfig = ComponentUtil.getFessConfig();
221         escapedHighlightPre = LaFunctions.h(originalHighlightTagPre);
222         escapedHighlightPost = LaFunctions.h(originalHighlightTagPost);
223         highlightTagPre = fessConfig.getQueryHighlightTagPre();
224         highlightTagPost = fessConfig.getQueryHighlightTagPost();
225         highlightedFields = fessConfig.getQueryHighlightContentDescriptionFieldsAsArray();
226         for (final int v : fessConfig.getQueryHighlightTerminalCharsAsArray()) {
227             highlightTerminalCharSet.add(v);
228         }
229         try {
230             final ServletContext servletContext = ComponentUtil.getComponent(ServletContext.class);
231             servletContext.setSessionTrackingModes(
232                     fessConfig.getSessionTrackingModesAsSet().stream().map(SessionTrackingMode::valueOf).collect(Collectors.toSet()));
233         } catch (final Throwable t) {
234             logger.warn("Failed to set SessionTrackingMode.", t);
235         }
236 
237         split(fessConfig.getQueryFacetQueries(), "\n").of(stream -> stream.map(String::trim).filter(StringUtil::isNotEmpty).forEach(s -> {
238             final String[] values = StringUtils.split(s, ":", 2);
239             if (values.length != 2) {
240                 return;
241             }
242             final FacetQueryView facetQueryView = new FacetQueryView();
243             facetQueryView.setTitle(values[0]);
244             split(values[1], "\t").of(subStream -> subStream.map(String::trim).filter(StringUtil::isNotEmpty).forEach(v -> {
245                 final String[] facet = StringUtils.split(v, "=", 2);
246                 if (facet.length == 2) {
247                     facetQueryView.addQuery(facet[0], facet[1]);
248                 }
249             }));
250             facetQueryView.init();
251             facetQueryViewList.add(facetQueryView);
252             if (logger.isDebugEnabled()) {
253                 logger.debug("loaded {}", facetQueryView);
254             }
255         }));
256 
257         facetCache = CacheBuilder.newBuilder().maximumSize(1000).expireAfterWrite(facetCacheDuration, TimeUnit.SECONDS).build();
258 
259         textFragmentPrefixLength = fessConfig.getQueryHighlightTextFragmentPrefixLengthAsInteger();
260         textFragmentSuffixLength = fessConfig.getQueryHighlightTextFragmentSuffixLengthAsInteger();
261         textFragmentSize = fessConfig.getQueryHighlightTextFragmentSizeAsInteger();
262 
263         split(fessConfig.getResponseInlineMimetypes(), ",")
264                 .of(stream -> stream.map(String::trim).filter(StringUtil::isNotEmpty).forEach(inlineMimeTypeSet::add));
265     }
266 
267     /**
268      * Gets the display title for a document.
269      * Falls back to filename or URL if title is not available.
270      * Applies highlighting if enabled.
271      *
272      * @param document the document data map
273      * @return the content title with optional highlighting
274      */
275     public String getContentTitle(final Map<String, Object> document) {
276         final FessConfig fessConfig = ComponentUtil.getFessConfig();
277         String title = DocumentUtil.getValue(document, fessConfig.getIndexFieldTitle(), String.class);
278         if (StringUtil.isBlank(title)) {
279             title = DocumentUtil.getValue(document, fessConfig.getIndexFieldFilename(), String.class);
280             if (StringUtil.isBlank(title)) {
281                 title = DocumentUtil.getValue(document, fessConfig.getIndexFieldUrl(), String.class);
282             }
283         }
284         final int size = fessConfig.getResponseMaxTitleLengthAsInteger();
285         if (size > -1) {
286             title = StringUtils.abbreviate(title, size);
287         }
288         final String value = LaFunctions.h(title);
289         if (!fessConfig.isResponseHighlightContentTitleEnabled()) {
290             return value;
291         }
292         return getQuerySet().map(querySet -> {
293             final String pattern = querySet.stream().map(LaFunctions::h).map(Pattern::quote).collect(Collectors.joining("|"));
294             if (StringUtil.isBlank(pattern)) {
295                 return null;
296             }
297             final Matcher matcher = Pattern.compile(pattern, Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CASE).matcher(value);
298             final StringBuffer buf = new StringBuffer(value.length() + 100);
299             while (matcher.find()) {
300                 matcher.appendReplacement(buf, Matcher.quoteReplacement(highlightTagPre + matcher.group(0) + highlightTagPost));
301             }
302             matcher.appendTail(buf);
303             return buf.toString();
304         }).orElse(value);
305     }
306 
307     /**
308      * Gets the set of highlight queries from the current request.
309      *
310      * @return OptionalThing containing the query set
311      */
312     protected OptionalThing<Set<String>> getQuerySet() {
313         return LaRequestUtil.getOptionalRequest()
314                 .map(req -> ((Set<String>) req.getAttribute(Constants.HIGHLIGHT_QUERIES)))
315                 .filter(s -> s != null);
316     }
317 
318     /**
319      * Gets the content description from highlighted fields.
320      * Returns the first non-blank highlighted field content.
321      *
322      * @param document the document data map
323      * @return the content description with highlighting
324      */
325     public String getContentDescription(final Map<String, Object> document) {
326         for (final String field : highlightedFields) {
327             final String text = DocumentUtil.getValue(document, field, String.class);
328             if (StringUtil.isNotBlank(text)) {
329                 return escapeHighlight(text);
330             }
331         }
332 
333         return StringUtil.EMPTY;
334     }
335 
336     /**
337      * Escapes HTML and applies highlighting to text.
338      * Handles boundary position detection if enabled.
339      *
340      * @param text the text to process
341      * @return the escaped and highlighted text
342      */
343     protected String escapeHighlight(final String text) {
344         final String escaped = LaFunctions.h(text);
345         final String value;
346         if (ComponentUtil.getFessConfig().isQueryHighlightBoundaryPositionDetect()) {
347             int pos = escaped.indexOf(escapedHighlightPre);
348             while (pos >= 0) {
349                 final int c = escaped.codePointAt(pos);
350                 if (Character.isISOControl(c) || highlightTerminalCharSet.contains(c)) {
351                     break;
352                 }
353                 pos--;
354             }
355 
356             value = escaped.substring(pos + 1);
357         } else {
358             value = escaped;
359         }
360         return value.replaceAll(escapedHighlightPre, highlightTagPre).replaceAll(escapedHighlightPost, highlightTagPost);
361     }
362 
363     /**
364      * Removes highlight tags from a string.
365      *
366      * @param str the string containing highlight tags
367      * @return the string with highlight tags removed
368      */
369     protected String removeHighlightTag(final String str) {
370         return str.replaceAll(originalHighlightTagPre, StringUtil.EMPTY).replaceAll(originalHighlightTagPost, StringUtil.EMPTY);
371     }
372 
373     /**
374      * Creates highlight information based on screen width.
375      * Adjusts fragment size for mobile devices.
376      *
377      * @return the highlight information
378      */
379     public HighlightInfo createHighlightInfo() {
380         return LaRequestUtil.getOptionalRequest().map(req -> {
381             final HighlightInfo highlightInfo = new HighlightInfo();
382             final String widthStr = req.getParameter(SCREEN_WIDTH);
383             if (StringUtil.isNotBlank(widthStr)) {
384                 final int width = Integer.parseInt(widthStr);
385                 updateHighlightInfo(highlightInfo, width);
386                 final HttpSession session = req.getSession(false);
387                 if (session != null) {
388                     session.setAttribute(SCREEN_WIDTH, width);
389                 }
390             } else {
391                 final HttpSession session = req.getSession(false);
392                 if (session != null) {
393                     final Integer width = (Integer) session.getAttribute(SCREEN_WIDTH);
394                     if (width != null) {
395                         updateHighlightInfo(highlightInfo, width);
396                     }
397                 }
398             }
399             return highlightInfo;
400         }).orElse(new HighlightInfo());
401     }
402 
403     /**
404      * Updates highlight information based on screen width.
405      * Reduces fragment size for smaller screens.
406      *
407      * @param highlightInfo the highlight info to update
408      * @param width the screen width
409      */
410     protected void updateHighlightInfo(final HighlightInfo highlightInfo, final int width) {
411         if (width < TABLET_WIDTH) {
412             float ratio = (float) width / (float) TABLET_WIDTH;
413             if (ratio < 0.5) {
414                 ratio = 0.5f;
415             }
416             highlightInfo.fragmentSize((int) (highlightInfo.getFragmentSize() * ratio));
417         }
418     }
419 
420     /**
421      * Gets the URL link for a document with proper protocol handling.
422      * Handles file, SMB, FTP, and HTTP protocols.
423      *
424      * @param document the document data map
425      * @return the processed URL link
426      */
427     public String getUrlLink(final Map<String, Object> document) {
428         final FessConfig fessConfig = ComponentUtil.getFessConfig();
429         String url = DocumentUtil.getValue(document, fessConfig.getIndexFieldUrl(), String.class);
430 
431         if (StringUtil.isBlank(url)) {
432             return "#not-found-" + DocumentUtil.getValue(document, fessConfig.getIndexFieldDocId(), String.class);
433         }
434 
435         final boolean isSmbUrl = url.startsWith("smb:") || url.startsWith("smb1:");
436         final boolean isFtpUrl = url.startsWith("ftp:");
437         final boolean isSmbOrFtpUrl = isSmbUrl || isFtpUrl;
438 
439         // replacing url with mapping data
440         url = ComponentUtil.getPathMappingHelper().replaceUrl(url);
441 
442         final boolean isHttpUrl = url.startsWith("http:") || url.startsWith("https:");
443 
444         if (isSmbUrl) {
445             url = url.replace("smb:", "file:");
446             url = url.replace("smb1:", "file:");
447         }
448 
449         if (isHttpUrl && isSmbOrFtpUrl) {
450             //  smb/ftp->http
451             // encode
452             final StringBuilder buf = new StringBuilder(url.length() + 100);
453             for (final char c : url.toCharArray()) {
454                 if (CharUtil.isUrlChar(c)) {
455                     buf.append(c);
456                 } else {
457                     try {
458                         buf.append(URLEncoder.encode(String.valueOf(c), urlLinkEncoding));
459                     } catch (final UnsupportedEncodingException e) {
460                         buf.append(c);
461                     }
462                 }
463             }
464             url = buf.toString();
465         } else if (url.startsWith("file:")) {
466             // file, smb/ftp->http
467             url = updateFileProtocol(url);
468 
469             if (encodeUrlLink) {
470                 return appendQueryParameter(document, url);
471             }
472 
473             // decode
474             if (!isSmbOrFtpUrl) {
475                 // file
476                 try {
477                     url = URLDecoder.decode(url.replace("+", "%2B"), urlLinkEncoding);
478                 } catch (final Exception e) {
479                     if (logger.isDebugEnabled()) {
480                         logger.warn("Failed to decode url: url={}", url, e);
481                     }
482                 }
483             }
484         }
485         // http, ftp
486         // nothing
487 
488         return appendQueryParameter(document, url);
489     }
490 
491     /**
492      * Updates file protocol based on user agent type.
493      * Handles different browser-specific file protocol formats.
494      *
495      * @param url the file URL to update
496      * @return the updated URL with appropriate file protocol
497      */
498     protected String updateFileProtocol(String url) {
499         final int pos = url.indexOf(':', 5);
500         final boolean isLocalFile = pos > 0 && pos < 12;
501 
502         final UserAgentType ua = ComponentUtil.getUserAgentHelper().getUserAgentType();
503         final DynamicProperties systemProperties = ComponentUtil.getSystemProperties();
504         switch (ua) {
505         case IE:
506             if (isLocalFile) {
507                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.ie", "file://"));
508             } else {
509                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.ie", "file://"));
510             }
511             break;
512         case FIREFOX:
513             if (isLocalFile) {
514                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.firefox", "file://"));
515             } else {
516                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.firefox", "file://///"));
517             }
518             break;
519         case CHROME:
520             if (isLocalFile) {
521                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.chrome", "file://"));
522             } else {
523                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.chrome", "file://"));
524             }
525             break;
526         case SAFARI:
527             if (isLocalFile) {
528                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.safari", "file://"));
529             } else {
530                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.safari", "file:////"));
531             }
532             break;
533         case OPERA:
534             if (isLocalFile) {
535                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.opera", "file://"));
536             } else {
537                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.opera", "file://"));
538             }
539             break;
540         default:
541             if (isLocalFile) {
542                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.winlocal.other", "file://"));
543             } else {
544                 url = url.replaceFirst("file:/+", systemProperties.getProperty("file.protocol.other", "file://"));
545             }
546             break;
547         }
548         return url;
549     }
550 
551     /**
552      * Appends query parameters to URLs based on document type.
553      * Adds search highlighting for HTML and PDF documents.
554      *
555      * @param document the document data map
556      * @param url the base URL
557      * @return the URL with appended query parameters
558      */
559     protected String appendQueryParameter(final Map<String, Object> document, final String url) {
560         final FessConfig fessConfig = ComponentUtil.getFessConfig();
561         if (fessConfig.isAppendQueryParameter()) {
562             if (url.indexOf('#') >= 0) {
563                 return url;
564             }
565 
566             final String mimetype = DocumentUtil.getValue(document, fessConfig.getIndexFieldMimetype(), String.class);
567             if (StringUtil.isNotBlank(mimetype)) {
568                 switch (mimetype) {
569                 case "text/html":
570                     return appendHTMLSearchWord(document, url);
571                 case "application/pdf":
572                     return appendPDFSearchWord(document, url);
573                 default:
574                     break;
575                 }
576             }
577         }
578         return url;
579     }
580 
581     /**
582      * Appends text fragment parameters to HTML URLs for highlighting.
583      *
584      * @param document the document data map
585      * @param url the HTML URL
586      * @return the URL with text fragment parameters
587      */
588     protected String appendHTMLSearchWord(final Map<String, Object> document, final String url) {
589         final TextFragment[] textFragments = (TextFragment[]) document.get(Constants.TEXT_FRAGMENTS);
590         if (textFragments != null) {
591             final StringBuilder buf = new StringBuilder(1000);
592             buf.append(url).append("#:~:");
593             for (int i = 0; i < textFragmentSize && i < textFragments.length; i++) {
594                 buf.append(textFragments[i].toURLString()).append('&');
595             }
596             return buf.toString();
597         }
598         return url;
599     }
600 
601     /**
602      * Appends search parameters to PDF URLs for highlighting.
603      *
604      * @param document the document data map
605      * @param url the PDF URL
606      * @return the URL with search parameters
607      */
608     protected String appendPDFSearchWord(final Map<String, Object> document, final String url) {
609         return LaRequestUtil.getOptionalRequest().map(req -> (String) req.getAttribute(Constants.REQUEST_QUERIES)).map(queries -> {
610             try {
611                 final StringBuilder buf = new StringBuilder(url.length() + 100);
612                 buf.append(url).append("#search=%22");
613                 buf.append(URLEncoder.encode(queries.trim(), Constants.UTF_8));
614                 buf.append("%22");
615                 return buf.toString();
616             } catch (final UnsupportedEncodingException e) {
617                 logger.warn("Unsupported encoding.", e);
618             }
619             return null;
620         }).filter(StringUtil::isNotBlank).orElse(url);
621     }
622 
623     /**
624      * Gets the localized page path for a given page name.
625      * Checks for locale-specific versions before falling back to default.
626      *
627      * @param page the page name
628      * @return the localized page path
629      */
630     public String getPagePath(final String page) {
631         final Locale locale = ComponentUtil.getRequestManager().getUserLocale();
632         final String lang = locale.getLanguage();
633         final String country = locale.getCountry();
634 
635         final String pathLC = getLocalizedPagePath(page, lang, country);
636         final String pLC = pageCacheMap.get(pathLC);
637         if (pLC != null) {
638             return pLC;
639         }
640         if (existsPage(pathLC)) {
641             pageCacheMap.put(pathLC, pathLC);
642             return pathLC;
643         }
644 
645         final String pathL = getLocalizedPagePath(page, lang, null);
646         final String pL = pageCacheMap.get(pathL);
647         if (pL != null) {
648             return pL;
649         }
650         if (existsPage(pathL)) {
651             pageCacheMap.put(pathLC, pathL);
652             return pathL;
653         }
654 
655         final String path = getLocalizedPagePath(page, null, null);
656         final String p = pageCacheMap.get(path);
657         if (p != null) {
658             return p;
659         }
660         if (existsPage(path)) {
661             pageCacheMap.put(pathLC, path);
662             return path;
663         }
664 
665         return "index.jsp";
666     }
667 
668     /**
669      * Constructs a localized page path with language and country.
670      *
671      * @param page the page name
672      * @param lang the language code
673      * @param country the country code
674      * @return the localized page path
675      */
676     private String getLocalizedPagePath(final String page, final String lang, final String country) {
677         final StringBuilder buf = new StringBuilder(100);
678         buf.append("/WEB-INF/view/").append(page);
679         if (StringUtil.isNotBlank(lang)) {
680             buf.append('_').append(lang);
681             if (StringUtil.isNotBlank(country)) {
682                 buf.append('_').append(country);
683             }
684         }
685         buf.append(".jsp");
686         return buf.toString();
687     }
688 
689     /**
690      * Checks if a page file exists at the given path.
691      *
692      * @param path the page path to check
693      * @return true if the page exists, false otherwise
694      */
695     private boolean existsPage(final String path) {
696         final String realPath = LaServletContextUtil.getServletContext().getRealPath(path);
697         final File file = new File(realPath);
698         return file.isFile();
699     }
700 
701     /**
702      * Creates cached content with highlighting for a document.
703      * Uses Handlebars templates to render the cached content.
704      *
705      * @param doc the document data map
706      * @param queries the search queries for highlighting
707      * @return the rendered cache content
708      */
709     public String createCacheContent(final Map<String, Object> doc, final String[] queries) {
710         final FessConfig fessConfig = ComponentUtil.getFessConfig();
711         final FileTemplateLoader loader = new FileTemplateLoader(ResourceUtil.getViewTemplatePath().toFile());
712         final Handlebars handlebars = new Handlebars(loader);
713 
714         Locale locale = ComponentUtil.getRequestManager().getUserLocale();
715         if (locale == null) {
716             locale = Locale.ENGLISH;
717         }
718         String url = DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class);
719         if (url == null) {
720             url = ComponentUtil.getMessageManager().getMessage(locale, "labels.search_unknown");
721         }
722         doc.put(fessConfig.getResponseFieldUrlLink(), getUrlLink(doc));
723         String createdStr;
724         final Date created = DocumentUtil.getValue(doc, fessConfig.getIndexFieldCreated(), Date.class);
725         if (created != null) {
726             final SimpleDateFormat sdf = new SimpleDateFormat(CoreLibConstants.DATE_FORMAT_ISO_8601_EXTEND);
727             createdStr = sdf.format(created);
728         } else {
729             createdStr = ComponentUtil.getMessageManager().getMessage(locale, "labels.search_unknown");
730         }
731         doc.put(CACHE_MSG, ComponentUtil.getMessageManager().getMessage(locale, "labels.search_cache_msg", url, createdStr));
732 
733         doc.put(QUERIES, queries);
734 
735         String cache = DocumentUtil.getValue(doc, fessConfig.getIndexFieldCache(), String.class);
736         if (cache != null) {
737             final String mimetype = DocumentUtil.getValue(doc, fessConfig.getIndexFieldMimetype(), String.class);
738             if (!ComponentUtil.getFessConfig().isHtmlMimetypeForCache(mimetype)) {
739                 cache = StringEscapeUtils.escapeHtml4(cache);
740             }
741             cache = ComponentUtil.getPathMappingHelper().replaceUrls(cache);
742             if (queries != null && queries.length > 0) {
743                 doc.put(HL_CACHE, replaceHighlightQueries(cache, queries));
744             } else {
745                 doc.put(HL_CACHE, cache);
746             }
747         } else {
748             doc.put(fessConfig.getIndexFieldCache(), StringUtil.EMPTY);
749             doc.put(HL_CACHE, StringUtil.EMPTY);
750         }
751 
752         try {
753             final Template template = handlebars.compile(cacheTemplateName);
754             final Context hbsContext = Context.newContext(doc);
755             return template.apply(hbsContext);
756         } catch (final Exception e) {
757             logger.warn("Failed to create a cache response.", e);
758         }
759 
760         return null;
761     }
762 
763     /**
764      * Replaces search queries with highlighted versions in cached content.
765      * Preserves HTML tags while highlighting text content.
766      *
767      * @param cache the cached content
768      * @param queries the search queries to highlight
769      * @return the content with highlighted queries
770      */
771     protected String replaceHighlightQueries(final String cache, final String[] queries) {
772         final StringBuffer buf = new StringBuffer(cache.length() + 100);
773         final StringBuffer segBuf = new StringBuffer(1000);
774         final Pattern p = Pattern.compile("<[^>]+>");
775         final Matcher m = p.matcher(cache);
776         final String[] regexQueries = new String[queries.length];
777         final String[] hlQueries = new String[queries.length];
778         for (int i = 0; i < queries.length; i++) {
779             regexQueries[i] = Pattern.quote(queries[i]);
780             hlQueries[i] = highlightTagPre + queries[i] + highlightTagPost;
781         }
782         while (m.find()) {
783             segBuf.setLength(0);
784             m.appendReplacement(segBuf, StringUtil.EMPTY);
785             String segment = segBuf.toString();
786             for (int i = 0; i < queries.length; i++) {
787                 segment = Pattern.compile(regexQueries[i], Pattern.CASE_INSENSITIVE).matcher(segment).replaceAll(hlQueries[i]);
788             }
789             buf.append(segment);
790             buf.append(m.group(0));
791         }
792         segBuf.setLength(0);
793         m.appendTail(segBuf);
794         String segment = segBuf.toString();
795         for (int i = 0; i < queries.length; i++) {
796             segment = Pattern.compile(regexQueries[i], Pattern.CASE_INSENSITIVE).matcher(segment).replaceAll(hlQueries[i]);
797         }
798         buf.append(segment);
799         return buf.toString();
800     }
801 
802     /**
803      * Gets the site path for display purposes.
804      * Extracts and formats the site path from document URL.
805      *
806      * @param docMap the document data map
807      * @return the formatted site path
808      */
809     public Object getSitePath(final Map<String, Object> docMap) {
810         final FessConfig fessConfig = ComponentUtil.getFessConfig();
811         final Object siteValue = docMap.get(fessConfig.getIndexFieldSite());
812         if (siteValue != null) {
813             final String site = siteValue.toString();
814             final int size = fessConfig.getResponseMaxSitePathLengthAsInteger();
815             if (size > 3) {
816                 return StringUtils.abbreviate(site, size);
817             }
818             if (size >= 0) {
819                 return site;
820             }
821         }
822         final Object urlLink = docMap.get(fessConfig.getResponseFieldUrlLink());
823         if (urlLink != null) {
824             final String returnUrl;
825             final String url = urlLink.toString();
826             if (LOCAL_PATH_PATTERN.matcher(url).find() || SHARED_FOLDER_PATTERN.matcher(url).find()) {
827                 returnUrl = url.replaceFirst("^file:/+", "");
828             } else if (url.startsWith("file:")) {
829                 returnUrl = url.replaceFirst("^file:/+", "/");
830             } else {
831                 returnUrl = url.replaceFirst("^[a-zA-Z0-9]*:/+", "");
832             }
833             final int size = fessConfig.getResponseMaxSitePathLengthAsInteger();
834             if (size > 3) {
835                 return StringUtils.abbreviate(returnUrl, size);
836             }
837             return returnUrl;
838         }
839         return null;
840     }
841 
842     /**
843      * Creates a stream response for document content delivery.
844      * Handles content retrieval and streaming to the client.
845      *
846      * @param doc the document data map
847      * @return the stream response containing document content
848      * @throws FessSystemException if content cannot be retrieved
849      */
850     public StreamResponse asContentResponse(final Map<String, Object> doc) {
851         if (logger.isDebugEnabled()) {
852             logger.debug("writing the content of: {}", doc);
853         }
854         final FessConfig fessConfig = ComponentUtil.getFessConfig();
855         final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper();
856         final String configId = DocumentUtil.getValue(doc, fessConfig.getIndexFieldConfigId(), String.class);
857         if (configId == null) {
858             final String docId = DocumentUtil.getValue(doc, fessConfig.getIndexFieldId(), String.class);
859             throw new FessSystemException("configId is null in document. docId: " + docId);
860         }
861         if (configId.length() < 2) {
862             throw new FessSystemException("Invalid configId length: " + configId + ". ConfigId must be at least 2 characters long.");
863         }
864         final CrawlingConfig config = crawlingConfigHelper.getCrawlingConfig(configId);
865         if (config == null) {
866             throw new FessSystemException("No crawlingConfig: " + configId);
867         }
868         final String url = DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class);
869         final CrawlerClientFactory crawlerClientFactory =
870                 config.initializeClientFactory(() -> ComponentUtil.getComponent(CrawlerClientFactory.class));
871         final CrawlerClient client = crawlerClientFactory.getClient(url);
872         if (client == null) {
873             throw new FessSystemException("No CrawlerClient: " + configId + ", url: " + url);
874         }
875         return writeContent(configId, url, client);
876     }
877 
878     /**
879      * Writes content from a crawler client to a stream response.
880      *
881      * @param configId the configuration ID
882      * @param url the document URL
883      * @param client the crawler client
884      * @return the stream response with document content
885      */
886     protected StreamResponse writeContent(final String configId, final String url, final CrawlerClient client) {
887         final StreamResponse response = new StreamResponse(StringUtil.EMPTY);
888         final ResponseData responseData = client.execute(RequestDataBuilder.newRequestData().get().url(url).build());
889         if (responseData.getHttpStatusCode() == 404) {
890             response.httpStatus(responseData.getHttpStatusCode());
891             CloseableUtil.closeQuietly(responseData);
892             return response;
893         }
894         writeFileName(response, responseData);
895         writeContentType(response, responseData);
896         writeNoCache(response, responseData);
897         response.stream(out -> {
898             try (final InputStream is = new BufferedInputStream(responseData.getResponseBody())) {
899                 out.write(is);
900             } catch (final IOException e) {
901                 if (!(e.getCause() instanceof ClientAbortException)) {
902                     throw new FessSystemException("Failed to write a content. configId: " + configId + ", url: " + url, e);
903                 }
904             } finally {
905                 CloseableUtil.closeQuietly(responseData);
906             }
907             if (logger.isDebugEnabled()) {
908                 logger.debug("Finished to write {}", url);
909             }
910         });
911         return response;
912     }
913 
914     /**
915      * Writes no-cache headers to the response.
916      *
917      * @param response the stream response
918      * @param responseData the response data
919      */
920     protected void writeNoCache(final StreamResponse response, final ResponseData responseData) {
921         response.header("Pragma", "no-cache");
922         response.header("Cache-Control", "no-cache");
923         response.header("Expires", "Thu, 01 Dec 1994 16:00:00 GMT");
924     }
925 
926     /**
927      * Writes content disposition header with filename.
928      *
929      * @param response the stream response
930      * @param responseData the response data
931      */
932     protected void writeFileName(final StreamResponse response, final ResponseData responseData) {
933         String charset = responseData.getCharSet();
934         if (charset == null) {
935             charset = Constants.UTF_8;
936         }
937         final String name;
938         final String url = responseData.getUrl();
939         final int pos = url.lastIndexOf('/');
940         try {
941             if (pos >= 0 && pos + 1 < url.length()) {
942                 name = URLDecoder.decode(url.substring(pos + 1), charset);
943             } else {
944                 name = URLDecoder.decode(url, charset);
945             }
946 
947             final String contentDispositionType;
948             if (inlineMimeTypeSet.contains(responseData.getMimeType())) {
949                 contentDispositionType = "inline";
950             } else {
951                 contentDispositionType = "attachment";
952             }
953 
954             final String encodedName = URLEncoder.encode(name, Constants.UTF_8).replace("+", "%20");
955             final String contentDispositionValue;
956             if (name.equals(encodedName)) {
957                 contentDispositionValue = contentDispositionType + "; filename=\"" + name + "\"";
958             } else {
959                 contentDispositionValue = contentDispositionType + "; filename*=utf-8''" + encodedName;
960             }
961             if (logger.isDebugEnabled()) {
962                 logger.debug("ResponseHeader: {}: {}", CONTENT_DISPOSITION, contentDispositionValue);
963             }
964             response.header(CONTENT_DISPOSITION, contentDispositionValue);
965         } catch (final Exception e) {
966             logger.warn("Failed to write a filename: {}", responseData, e);
967         }
968     }
969 
970     /**
971      * Writes content type header to the response.
972      *
973      * @param response the stream response
974      * @param responseData the response data
975      */
976     protected void writeContentType(final StreamResponse response, final ResponseData responseData) {
977         final String mimeType = responseData.getMimeType();
978         if (logger.isDebugEnabled()) {
979             logger.debug("mimeType: {}", mimeType);
980         }
981         if (mimeType == null) {
982             response.contentTypeOctetStream();
983             return;
984         }
985         if (mimeType.startsWith("text/")) {
986             final String charset = responseData.getCharSet();
987             if (charset != null) {
988                 response.contentType(mimeType + "; charset=" + charset);
989                 return;
990             }
991         }
992         response.contentType(mimeType);
993     }
994 
995     /**
996      * Gets the client IP address from the request.
997      * Checks X-Forwarded-For header before using remote address.
998      *
999      * @param request the HTTP servlet request
1000      * @return the client IP address
1001      */
1002     public String getClientIp(final HttpServletRequest request) {
1003         final String value = request.getHeader("x-forwarded-for");
1004         if (StringUtil.isNotBlank(value)) {
1005             return value;
1006         }
1007         return request.getRemoteAddr();
1008     }
1009 
1010     /**
1011      * Gets a cached facet response for the given query.
1012      * Creates and caches the response if not already cached.
1013      *
1014      * @param query the search query
1015      * @return the facet response
1016      * @throws FessSystemException if facet data cannot be loaded
1017      */
1018     public FacetResponse getCachedFacetResponse(final String query) {
1019         final OptionalThing<FessUserBean> userBean = ComponentUtil.getComponent(FessLoginAssist.class).getSavedUserBean();
1020         final String permissionKey = userBean.map(user -> StreamUtil.stream(user.getPermissions())
1021                 .get(stream -> stream.sorted().distinct().collect(Collectors.joining("\n")))).orElse(StringUtil.EMPTY);
1022 
1023         try {
1024             return facetCache.get(query + "\n" + permissionKey, () -> {
1025                 final SearchHelper searchHelper = ComponentUtil.getSearchHelper();
1026                 final SearchForm params = new SearchForm() {
1027                     @Override
1028                     public int getPageSize() {
1029                         return 0;
1030                     }
1031 
1032                     @Override
1033                     public int getStartPosition() {
1034                         return 0;
1035                     }
1036                 };
1037                 params.q = query;
1038                 final SearchRenderData data = new SearchRenderData();
1039                 searchHelper.search(params, data, userBean);
1040                 if (logger.isDebugEnabled()) {
1041                     logger.debug("loaded facet data: {}", data);
1042                 }
1043                 return data.getFacetResponse();
1044             });
1045         } catch (final ExecutionException e) {
1046             throw new FessSystemException("Cannot load facet from cache.", e);
1047         }
1048     }
1049 
1050     /**
1051      * Creates highlighted text from highlight field fragments.
1052      *
1053      * @param highlightField the highlight field containing fragments
1054      * @return the combined highlighted text
1055      */
1056     public String createHighlightText(final HighlightField highlightField) {
1057         final Text[] fragments = highlightField.fragments();
1058         if (fragments != null && fragments.length != 0) {
1059             final String[] texts = new String[fragments.length];
1060             for (int i = 0; i < fragments.length; i++) {
1061                 texts[i] = fragments[i].string();
1062             }
1063             final String value = StringUtils.join(texts, ELLIPSIS);
1064             if (StringUtil.isNotBlank(value) && !ComponentUtil.getFessConfig().endsWithFullstop(value)) {
1065                 return value + ELLIPSIS;
1066             }
1067             return value;
1068         }
1069         return null;
1070     }
1071 
1072     /**
1073      * Creates text fragments from highlight fields for URL fragment navigation.
1074      *
1075      * @param fields the highlight fields
1076      * @return array of text fragments
1077      */
1078     public TextFragment[] createTextFragmentsByHighlight(final HighlightField[] fields) {
1079         final List<TextFragment> list = new ArrayList<>();
1080         for (final HighlightField field : fields) {
1081             final Text[] fragments = field.fragments();
1082             if (fragments != null) {
1083                 for (final Text fragment : fragments) {
1084                     final String text = fragment.string();
1085                     if (text.length() > textFragmentPrefixLength + textFragmentSuffixLength) {
1086                         final String target =
1087                                 text.replace(originalHighlightTagPre, StringUtil.EMPTY).replace(originalHighlightTagPost, StringUtil.EMPTY);
1088                         if (target.length() > textFragmentPrefixLength + textFragmentSuffixLength) {
1089                             list.add(new TextFragment(null, target.substring(0, textFragmentPrefixLength),
1090                                     target.substring(target.length() - textFragmentSuffixLength), null));
1091                         }
1092                     }
1093                 }
1094             }
1095         }
1096         return list.toArray(n -> new TextFragment[n]);
1097     }
1098 
1099     /**
1100      * Creates text fragments from search queries.
1101      *
1102      * @return array of text fragments based on current queries
1103      */
1104     public TextFragment[] createTextFragmentsByQuery() {
1105         return LaRequestUtil.getOptionalRequest().map(req -> {
1106             @SuppressWarnings("unchecked")
1107             final Set<String> querySet = (Set<String>) req.getAttribute(Constants.HIGHLIGHT_QUERIES);
1108             if (querySet != null) {
1109                 return querySet.stream().map(s -> new TextFragment(null, s, null, null)).toArray(n -> new TextFragment[n]);
1110             }
1111             return new TextFragment[0];
1112         }).orElse(new TextFragment[0]);
1113     }
1114 
1115     /**
1116      * Checks if HTTP sessions are enabled.
1117      *
1118      * @return true if sessions are used, false otherwise
1119      */
1120     public boolean isUseSession() {
1121         return useSession;
1122     }
1123 
1124     /**
1125      * Sets whether to use HTTP sessions.
1126      *
1127      * @param useSession true to enable sessions, false to disable
1128      */
1129     public void setUseSession(final boolean useSession) {
1130         this.useSession = useSession;
1131     }
1132 
1133     /**
1134      * Adds an initial facet parameter mapping.
1135      *
1136      * @param key the parameter key
1137      * @param value the parameter value
1138      */
1139     public void addInitFacetParam(final String key, final String value) {
1140         initFacetParamMap.put(value, key);
1141     }
1142 
1143     /**
1144      * Gets the initial facet parameter mappings.
1145      *
1146      * @return the facet parameter map
1147      */
1148     public Map<String, String> getInitFacetParamMap() {
1149         return initFacetParamMap;
1150     }
1151 
1152     /**
1153      * Adds an initial geographic parameter mapping.
1154      *
1155      * @param key the parameter key
1156      * @param value the parameter value
1157      */
1158     public void addInitGeoParam(final String key, final String value) {
1159         initGeoParamMap.put(value, key);
1160     }
1161 
1162     /**
1163      * Gets the initial geographic parameter mappings.
1164      *
1165      * @return the geographic parameter map
1166      */
1167     public Map<String, String> getInitGeoParamMap() {
1168         return initGeoParamMap;
1169     }
1170 
1171     /**
1172      * Adds a facet query view to the list.
1173      *
1174      * @param facetQueryView the facet query view to add
1175      */
1176     public void addFacetQueryView(final FacetQueryView facetQueryView) {
1177         facetQueryViewList.add(facetQueryView);
1178     }
1179 
1180     /**
1181      * Gets the list of facet query views.
1182      *
1183      * @return the list of facet query views
1184      */
1185     public List<FacetQueryView> getFacetQueryViewList() {
1186         return facetQueryViewList;
1187     }
1188 
1189     /**
1190      * Adds a MIME type to the inline display set.
1191      *
1192      * @param mimeType the MIME type to display inline
1193      */
1194     public void addInlineMimeType(final String mimeType) {
1195         inlineMimeTypeSet.add(mimeType);
1196     }
1197 
1198     /**
1199      * Gets the action hook for custom processing.
1200      *
1201      * @return the action hook
1202      */
1203     public ActionHook getActionHook() {
1204         return actionHook;
1205     }
1206 
1207     /**
1208      * Sets the action hook for custom processing.
1209      *
1210      * @param actionHook the action hook to set
1211      */
1212     public void setActionHook(final ActionHook actionHook) {
1213         this.actionHook = actionHook;
1214     }
1215 
1216     /**
1217      * Sets whether to encode URL links.
1218      *
1219      * @param encodeUrlLink true to encode URL links, false otherwise
1220      */
1221     public void setEncodeUrlLink(final boolean encodeUrlLink) {
1222         this.encodeUrlLink = encodeUrlLink;
1223     }
1224 
1225     /**
1226      * Sets the character encoding for URL links.
1227      *
1228      * @param urlLinkEncoding the character encoding to use
1229      */
1230     public void setUrlLinkEncoding(final String urlLinkEncoding) {
1231         this.urlLinkEncoding = urlLinkEncoding;
1232     }
1233 
1234     /**
1235      * Sets the original highlight tag prefix.
1236      *
1237      * @param originalHighlightTagPre the highlight tag prefix
1238      */
1239     public void setOriginalHighlightTagPre(final String originalHighlightTagPre) {
1240         this.originalHighlightTagPre = originalHighlightTagPre;
1241     }
1242 
1243     /**
1244      * Sets the original highlight tag suffix.
1245      *
1246      * @param originalHighlightTagPost the highlight tag suffix
1247      */
1248     public void setOriginalHighlightTagPost(final String originalHighlightTagPost) {
1249         this.originalHighlightTagPost = originalHighlightTagPost;
1250     }
1251 
1252     /**
1253      * Sets the cache template name.
1254      *
1255      * @param cacheTemplateName the template name for cache content
1256      */
1257     public void setCacheTemplateName(final String cacheTemplateName) {
1258         this.cacheTemplateName = cacheTemplateName;
1259     }
1260 
1261     /**
1262      * Sets the facet cache duration in seconds.
1263      *
1264      * @param facetCacheDuration the cache duration in seconds
1265      */
1266     public void setFacetCacheDuration(final long facetCacheDuration) {
1267         this.facetCacheDuration = facetCacheDuration;
1268     }
1269 
1270     /**
1271      * Hook class for customizing action processing.
1272      * Provides extension points for action lifecycle management.
1273      */
1274     public static class ActionHook {
1275 
1276         /**
1277          * Default constructor for ActionHook.
1278          */
1279         public ActionHook() {
1280             // Default constructor
1281         }
1282 
1283         /**
1284          * Prologue hook for action processing.
1285          *
1286          * @param runtime the action runtime
1287          * @param func the function to execute
1288          * @return the action response
1289          */
1290         public ActionResponse godHandPrologue(final ActionRuntime runtime, final Function<ActionRuntime, ActionResponse> func) {
1291             return func.apply(runtime);
1292         }
1293 
1294         /**
1295          * Monologue hook for action processing.
1296          *
1297          * @param runtime the action runtime
1298          * @param func the function to execute
1299          * @return the action response
1300          */
1301         public ActionResponse godHandMonologue(final ActionRuntime runtime, final Function<ActionRuntime, ActionResponse> func) {
1302             return func.apply(runtime);
1303         }
1304 
1305         /**
1306          * Epilogue hook for action processing.
1307          *
1308          * @param runtime the action runtime
1309          * @param consumer the consumer to execute
1310          */
1311         public void godHandEpilogue(final ActionRuntime runtime, final Consumer<ActionRuntime> consumer) {
1312             consumer.accept(runtime);
1313         }
1314 
1315         /**
1316          * Before hook for action processing.
1317          *
1318          * @param runtime the action runtime
1319          * @param func the function to execute
1320          * @return the action response
1321          */
1322         public ActionResponse hookBefore(final ActionRuntime runtime, final Function<ActionRuntime, ActionResponse> func) {
1323             return func.apply(runtime);
1324         }
1325 
1326         /**
1327          * Finally hook for action processing.
1328          *
1329          * @param runtime the action runtime
1330          * @param consumer the consumer to execute
1331          */
1332         public void hookFinally(final ActionRuntime runtime, final Consumer<ActionRuntime> consumer) {
1333             consumer.accept(runtime);
1334         }
1335     }
1336 
1337     /**
1338      * Represents a text fragment for URL-based text highlighting.
1339      * Used for creating browser text fragment URLs (#:~:text=...).
1340      */
1341     public static class TextFragment {
1342         /** Optional prefix text before the target */
1343         private final String prefix;
1344         /** Start of the target text */
1345         private final String textStart;
1346         /** Optional end of the target text */
1347         private final String textEnd;
1348         /** Optional suffix text after the target */
1349         private final String suffix;
1350 
1351         /**
1352          * Constructs a new TextFragment.
1353          *
1354          * @param prefix optional prefix text
1355          * @param textStart start of the target text
1356          * @param textEnd optional end of the target text
1357          * @param suffix optional suffix text
1358          */
1359         TextFragment(final String prefix, final String textStart, final String textEnd, final String suffix) {
1360             this.prefix = prefix;
1361             this.textStart = textStart == null ? StringUtil.EMPTY : textStart;
1362             this.textEnd = textEnd;
1363             this.suffix = suffix;
1364         }
1365 
1366         /**
1367          * Converts this text fragment to a URL string parameter.
1368          *
1369          * @return the URL-encoded text fragment parameter
1370          */
1371         public String toURLString() {
1372             final StringBuilder buf = new StringBuilder();
1373             buf.append("text=");
1374             if (StringUtil.isNotBlank(prefix)) {
1375                 buf.append(encodeToString(prefix)).append("-,");
1376             }
1377             buf.append(encodeToString(textStart));
1378             if (StringUtil.isNotBlank(textEnd)) {
1379                 buf.append(',').append(encodeToString(textEnd));
1380             }
1381             if (StringUtil.isNotBlank(suffix)) {
1382                 buf.append(",-").append(encodeToString(suffix));
1383             }
1384             return buf.toString();
1385         }
1386 
1387         /**
1388          * URL-encodes text for use in text fragment parameters.
1389          *
1390          * @param text the text to encode
1391          * @return the URL-encoded text
1392          */
1393         private String encodeToString(final String text) {
1394             return URLEncoder.encode(text, Constants.CHARSET_UTF_8);
1395         }
1396     }
1397 }