Jelajahi Sumber

fix #1535 add advanced search page

Shinsuke Sugaya 7 tahun lalu
induk
melakukan
05313622fa

+ 13 - 0
src/main/java/org/codelibs/fess/api/gsa/GsaApiManager.java

@@ -435,6 +435,19 @@ public class GsaApiManager extends BaseApiManager implements WebApiManager {
             return fields;
         }
 
+        @Override
+        public Map<String, String[]> getConditions() {
+            final Map<String, String[]> conditions = new HashMap<>();
+            for (final Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
+                final String key = entry.getKey();
+                if (key.startsWith("as_")) {
+                    final String[] value = simplifyArray(entry.getValue());
+                    conditions.put(key.substring("as_".length()), value);
+                }
+            }
+            return conditions;
+        }
+
         @Override
         public String[] getLanguages() {
             return getParamValueArray(request, "lang");

+ 13 - 0
src/main/java/org/codelibs/fess/api/json/JsonApiManager.java

@@ -707,6 +707,19 @@ public class JsonApiManager extends BaseJsonApiManager {
             return fields;
         }
 
+        @Override
+        public Map<String, String[]> getConditions() {
+            final Map<String, String[]> conditions = new HashMap<>();
+            for (final Map.Entry<String, String[]> entry : request.getParameterMap().entrySet()) {
+                final String key = entry.getKey();
+                if (key.startsWith("as.")) {
+                    final String[] value = simplifyArray(entry.getValue());
+                    conditions.put(key.substring("as.".length()), value);
+                }
+            }
+            return conditions;
+        }
+
         @Override
         public String[] getLanguages() {
             return getParamValueArray(request, "lang");

+ 7 - 1
src/main/java/org/codelibs/fess/api/suggest/SuggestApiManager.java

@@ -18,6 +18,7 @@ package org.codelibs.fess.api.suggest;
 import static org.codelibs.core.stream.StreamUtil.stream;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.Locale;
 import java.util.Map;
 
@@ -217,7 +218,12 @@ public class SuggestApiManager extends BaseJsonApiManager {
 
         @Override
         public Map<String, String[]> getFields() {
-            throw new UnsupportedOperationException();
+            return Collections.emptyMap();
+        }
+
+        @Override
+        public Map<String, String[]> getConditions() {
+            return Collections.emptyMap();
         }
 
         public String[] getTags() {

+ 3 - 7
src/main/java/org/codelibs/fess/app/service/SearchService.java

@@ -47,7 +47,6 @@ import org.codelibs.fess.mylasta.action.FessUserBean;
 import org.codelibs.fess.mylasta.direction.FessConfig;
 import org.codelibs.fess.util.ComponentUtil;
 import org.codelibs.fess.util.QueryResponseList;
-import org.codelibs.fess.util.QueryStringBuilder;
 import org.dbflute.optional.OptionalEntity;
 import org.dbflute.optional.OptionalThing;
 import org.dbflute.util.DfTypeUtil;
@@ -98,8 +97,7 @@ public class SearchService {
             request.setAttribute(Constants.REQUEST_QUERIES, params.getQuery());
         });
 
-        final String query =
-                QueryStringBuilder.query(params.getQuery()).extraQueries(params.getExtraQueries()).fields(params.getFields()).build();
+        final String query = ComponentUtil.getQueryStringBuilder().params(params).build();
 
         final int pageStart = params.getStartPosition();
         final int pageSize = params.getPageSize();
@@ -186,8 +184,7 @@ public class SearchService {
             request.setAttribute(Constants.REQUEST_QUERIES, params.getQuery());
         });
 
-        final String query =
-                QueryStringBuilder.query(params.getQuery()).extraQueries(params.getExtraQueries()).fields(params.getFields()).build();
+        final String query = ComponentUtil.getQueryStringBuilder().params(params).build();
 
         final int pageSize = params.getPageSize();
         final String sortField = params.getSort();
@@ -223,8 +220,7 @@ public class SearchService {
 
     public long deleteByQuery(final HttpServletRequest request, final SearchRequestParams params) {
 
-        final String query =
-                QueryStringBuilder.query(params.getQuery()).extraQueries(params.getExtraQueries()).fields(params.getFields()).build();
+        final String query = ComponentUtil.getQueryStringBuilder().params(params).build();
 
         final QueryContext queryContext = queryHelper.build(params.getType(), query, context -> {
             context.skipRoleQuery();

+ 7 - 0
src/main/java/org/codelibs/fess/app/web/admin/searchlist/ListForm.java

@@ -56,6 +56,8 @@ public class ListForm implements SearchRequestParams {
 
     public Map<String, String[]> fields = new HashMap<>();
 
+    public Map<String, String[]> as = new HashMap<>();
+
     public String[] ex_q;
 
     public String sdh;
@@ -75,6 +77,11 @@ public class ListForm implements SearchRequestParams {
         return fields;
     }
 
+    @Override
+    public Map<String, String[]> getConditions() {
+        return as;
+    }
+
     @Override
     public int getStartPosition() {
         if (start == null) {

+ 7 - 0
src/main/java/org/codelibs/fess/app/web/base/SearchForm.java

@@ -36,6 +36,8 @@ public class SearchForm implements SearchRequestParams {
 
     public Map<String, String[]> fields = new HashMap<>();
 
+    public Map<String, String[]> as = new HashMap<>();
+
     @Size(max = 1000)
     public String q;
 
@@ -144,4 +146,9 @@ public class SearchForm implements SearchRequestParams {
     public String getSimilarDocHash() {
         return sdh;
     }
+
+    @Override
+    public Map<String, String[]> getConditions() {
+        return as;
+    }
 }

+ 47 - 13
src/main/java/org/codelibs/fess/app/web/search/SearchAction.java

@@ -77,6 +77,28 @@ public class SearchAction extends FessSearchAction {
         return search(form);
     }
 
+    @Execute
+    public HtmlResponse advance(final SearchForm form) {
+        if (isLoginRequired()) {
+            return redirectToLogin();
+        }
+        validate(form, messages -> {}, () -> asHtml(virtualHost(path_IndexJsp)).renderWith(data -> {
+            buildInitParams();
+            RenderDataUtil.register(data, "notification", fessConfig.getNotificationSearchTop());
+        }));
+        if (!form.hasConditionQuery()) {
+            if (StringUtil.isNotBlank(form.q)) {
+                form.as.put("q", new String[] { form.q });
+            } else {
+                // TODO set default?
+            }
+        }
+        return asHtml(virtualHost(path_AdvanceJsp)).renderWith(data -> {
+            buildInitParams();
+            RenderDataUtil.register(data, "notification", fessConfig.getNotificationLogin());
+        });
+    }
+
     @Execute
     public HtmlResponse search(final SearchForm form) {
         if (viewHelper.isUseSession()) {
@@ -121,7 +143,7 @@ public class SearchAction extends FessSearchAction {
             }
         }
 
-        if (StringUtil.isBlank(form.q) && form.fields.isEmpty()) {
+        if (StringUtil.isBlank(form.q) && form.fields.isEmpty() && form.as.isEmpty()) {
             // redirect to index page
             form.q = null;
             return redirectToRoot();
@@ -134,6 +156,9 @@ public class SearchAction extends FessSearchAction {
             searchService.search(form, renderData, getUserBean());
             return asHtml(virtualHost(path_SearchJsp)).renderWith(
                     data -> {
+                        if (form.hasConditionQuery()) {
+                            form.q = renderData.getSearchQuery();
+                        }
                         renderData.register(data);
                         RenderDataUtil.register(data, "displayQuery",
                                 getDisplayQuery(form, labelTypeHelper.getLabelTypeItemList(SearchRequestType.SEARCH)));
@@ -233,18 +258,27 @@ public class SearchAction extends FessSearchAction {
                 }
             }
         }
-        if (!form.fields.isEmpty()) {
-            for (final Map.Entry<String, String[]> entry : form.fields.entrySet()) {
-                final String[] values = entry.getValue();
-                if (values != null) {
-                    for (final String v : values) {
-                        if (StringUtil.isNotBlank(v)) {
-                            pagingQueryList.add("fields." + LaFunctions.u(entry.getKey()) + "=" + LaFunctions.u(v));
-                        }
-                    }
-                }
-            }
-        }
+        form.fields
+                .entrySet()
+                .stream()
+                .filter(e -> e.getValue() != null)
+                .forEach(
+                        e -> {
+                            final String key = LaFunctions.u(e.getKey());
+                            stream(e.getValue()).of(
+                                    stream -> stream.filter(StringUtil::isNotBlank).forEach(
+                                            s -> pagingQueryList.add("fields." + key + "=" + LaFunctions.u(s))));
+                        });
+        form.as.entrySet()
+                .stream()
+                .filter(e -> e.getValue() != null)
+                .forEach(
+                        e -> {
+                            final String key = LaFunctions.u(e.getKey());
+                            stream(e.getValue()).of(
+                                    stream -> stream.filter(StringUtil::isNotBlank).forEach(
+                                            s -> pagingQueryList.add("as." + key + "=" + LaFunctions.u(s))));
+                        });
         request.setAttribute(Constants.PAGING_QUERY_LIST, pagingQueryList);
     }
 

+ 25 - 0
src/main/java/org/codelibs/fess/entity/SearchRequestParams.java

@@ -26,10 +26,20 @@ import org.codelibs.core.lang.StringUtil;
 
 public interface SearchRequestParams {
 
+    String AS_NQ = "nq";
+
+    String AS_OQ = "oq";
+
+    String AS_EPQ = "epq";
+
+    String AS_Q = "q";
+
     String getQuery();
 
     Map<String, String[]> getFields();
 
+    Map<String, String[]> getConditions();
+
     String[] getLanguages();
 
     GeoInfo getGeoInfo();
@@ -52,6 +62,21 @@ public interface SearchRequestParams {
 
     String getSimilarDocHash();
 
+    public default boolean hasConditionQuery() {
+        final Map<String, String[]> conditions = getConditions();
+        return !isEmptyArray(conditions.get(AS_Q))//
+                || !isEmptyArray(conditions.get(AS_EPQ))//
+                || !isEmptyArray(conditions.get(AS_OQ))//
+                || !isEmptyArray(conditions.get(AS_NQ));
+    }
+
+    public default boolean isEmptyArray(String[] values) {
+        if (values == null || values.length == 0) {
+            return true;
+        }
+        return stream(values).get(stream -> stream.allMatch(StringUtil::isBlank));
+    }
+
     public default String[] simplifyArray(final String[] values) {
         return stream(values).get(stream -> stream.filter(StringUtil::isNotBlank).distinct().toArray(n -> new String[n]));
     }

+ 3 - 0
src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java

@@ -361,6 +361,9 @@ public interface FessHtmlPath {
     /** The path of the HTML: /admin/wizard/admin_wizard_start.jsp */
     HtmlNext path_AdminWizard_AdminWizardStartJsp = new HtmlNext("/admin/wizard/admin_wizard_start.jsp");
 
+    /** The path of the HTML: /advance.jsp */
+    HtmlNext path_AdvanceJsp = new HtmlNext("/advance.jsp");
+
     /** The path of the HTML: /error/badRequest.jsp */
     HtmlNext path_Error_BadRequestJsp = new HtmlNext("/error/badRequest.jsp");
 

+ 18 - 0
src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java

@@ -2679,6 +2679,24 @@ public class FessLabels extends UserMessages {
     /** The key of the message: Running as Development mode. For production use, please install a standalone elasticsearch server. */
     public static final String LABELS_development_mode_warning = "{labels.development_mode_warning}";
 
+    /** The key of the message: Advance */
+    public static final String LABELS_ADVANCE = "{labels.advance}";
+
+    /** The key of the message: Advanced Search */
+    public static final String LABELS_advance_search_title = "{labels.advance_search_title}";
+
+    /** The key of the message: All these words: */
+    public static final String LABELS_advance_search_must_queries = "{labels.advance_search_must_queries}";
+
+    /** The key of the message: This exact word or phrase: */
+    public static final String LABELS_advance_search_phrase_query = "{labels.advance_search_phrase_query}";
+
+    /** The key of the message: Any of these words: */
+    public static final String LABELS_advance_search_should_queries = "{labels.advance_search_should_queries}";
+
+    /** The key of the message: None of these words: */
+    public static final String LABELS_advance_search_not_queries = "{labels.advance_search_not_queries}";
+
     /**
      * Assert the property is not null.
      * @param property The value of the property. (NotNull)

+ 18 - 0
src/main/java/org/codelibs/fess/taglib/FessFunctions.java

@@ -15,6 +15,8 @@
  */
 package org.codelibs.fess.taglib;
 
+import static org.codelibs.core.stream.StreamUtil.stream;
+
 import java.io.File;
 import java.math.RoundingMode;
 import java.nio.file.Files;
@@ -31,6 +33,7 @@ import java.util.Enumeration;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Collectors;
@@ -294,4 +297,19 @@ public class FessFunctions {
         }
         return ComponentUtil.getDocumentHelper().encodeSimilarDocHash(input);
     }
+
+    public static String join(final Object input) {
+        String[] values = null;
+        if (input instanceof String[]) {
+            values = (String[]) input;
+        } else if (input instanceof List) {
+            values = ((List<?>) input).stream().filter(Objects::nonNull).map(Object::toString).toArray(n -> new String[n]);
+        } else if (input instanceof String) {
+            return input.toString();
+        }
+        if (values != null) {
+            return stream(values).get(stream -> stream.filter(StringUtil::isNotBlank).map(String::trim).collect(Collectors.joining(" ")));
+        }
+        return StringUtil.EMPTY;
+    }
 }

+ 10 - 0
src/main/java/org/codelibs/fess/util/ComponentUtil.java

@@ -82,6 +82,8 @@ public final class ComponentUtil {
 
     private static final Logger logger = LoggerFactory.getLogger(ComponentUtil.class);
 
+    private static final String QUERY_STRING_BUILDER = "queryStringBuilder";
+
     private static final String ACCESS_TOKEN_HELPER = "accessTokenHelper";
 
     private static final String AUTHENTICATION_MANAGER = "authenticationManager";
@@ -421,6 +423,10 @@ public final class ComponentUtil {
         return getComponent(ACCESS_TOKEN_HELPER);
     }
 
+    public static QueryStringBuilder getQueryStringBuilder() {
+        return getComponent(QUERY_STRING_BUILDER);
+    }
+
     public static <T> T getComponent(final Class<T> clazz) {
         try {
             return SingletonLaContainer.getComponent(clazz);
@@ -457,6 +463,10 @@ public final class ComponentUtil {
         return SingletonLaContainerFactory.getContainer().hasComponentDef(POPULAR_WORD_HELPER);
     }
 
+    public static boolean hasRelatedQueryHelper() {
+        return SingletonLaContainerFactory.getContainer().hasComponentDef(RELATED_QUERY_HELPER);
+    }
+
     public static boolean available() {
         try {
             return SingletonLaContainer.getComponent(SYSTEM_HELPER) != null;

+ 72 - 30
src/main/java/org/codelibs/fess/util/QueryStringBuilder.java

@@ -15,20 +15,20 @@
  */
 package org.codelibs.fess.util;
 
+import static org.codelibs.core.stream.StreamUtil.split;
 import static org.codelibs.core.stream.StreamUtil.stream;
 
 import java.util.Map;
+import java.util.stream.Collectors;
 
 import org.codelibs.core.lang.StringUtil;
+import org.codelibs.fess.entity.SearchRequestParams;
 import org.codelibs.fess.helper.RelatedQueryHelper;
 import org.codelibs.fess.mylasta.direction.FessConfig;
 
 public class QueryStringBuilder {
-    private String query;
 
-    private String[] extraQueries;
-
-    private Map<String, String[]> fieldMap;
+    private SearchRequestParams params;
 
     protected String quote(final String value) {
         if (value.split("\\s").length > 1) {
@@ -39,26 +39,40 @@ public class QueryStringBuilder {
 
     public String build() {
         final FessConfig fessConfig = ComponentUtil.getFessConfig();
+        final int maxQueryLength = fessConfig.getQueryMaxLengthAsInteger().intValue();
         final StringBuilder queryBuf = new StringBuilder(255);
-        if (StringUtil.isNotBlank(query)) {
-            final RelatedQueryHelper relatedQueryHelper = ComponentUtil.getRelatedQueryHelper();
-            final String[] relatedQueries = relatedQueryHelper.getRelatedQueries(query);
-            if (relatedQueries.length == 0) {
-                queryBuf.append('(').append(query).append(')');
-            } else {
-                queryBuf.append('(');
-                queryBuf.append(quote(query));
-                for (final String s : relatedQueries) {
-                    queryBuf.append(" OR ");
-                    queryBuf.append(quote(s));
+
+        Map<String, String[]> conditions = params.getConditions();
+        if (params.hasConditionQuery()) {
+            appendConditions(queryBuf, conditions);
+        } else {
+            final String query = params.getQuery();
+            if (StringUtil.isNotBlank(query)) {
+                if (ComponentUtil.hasRelatedQueryHelper()) {
+                    final RelatedQueryHelper relatedQueryHelper = ComponentUtil.getRelatedQueryHelper();
+                    final String[] relatedQueries = relatedQueryHelper.getRelatedQueries(query);
+                    if (relatedQueries.length == 0) {
+                        queryBuf.append(query);
+                    } else {
+                        queryBuf.append('(');
+                        queryBuf.append(quote(query));
+                        for (final String s : relatedQueries) {
+                            queryBuf.append(" OR ");
+                            queryBuf.append(quote(s));
+                        }
+                        queryBuf.append(')');
+                    }
+                } else {
+                    queryBuf.append(query);
                 }
-                queryBuf.append(')');
             }
         }
-        stream(extraQueries).of(
-                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= fessConfig.getQueryMaxLengthAsInteger().intValue())
-                        .forEach(q -> queryBuf.append(' ').append(q)));
-        stream(fieldMap).of(stream -> stream.forEach(entry -> {
+
+        stream(params.getExtraQueries()).of(
+                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(
+                        q -> queryBuf.append(' ').append(q)));
+
+        stream(params.getFields()).of(stream -> stream.forEach(entry -> {
             final String key = entry.getKey();
             final String[] values = entry.getValue();
             if (values == null) {
@@ -79,22 +93,50 @@ public class QueryStringBuilder {
                 queryBuf.append(')');
             }
         }));
-        return queryBuf.toString();
+
+        return queryBuf.toString().trim();
     }
 
-    public static QueryStringBuilder query(final String query) {
-        final QueryStringBuilder builder = new QueryStringBuilder();
-        builder.query = query;
-        return builder;
+    protected void appendConditions(StringBuilder queryBuf, Map<String, String[]> conditions) {
+        if (conditions == null) {
+            return;
+        }
+        final FessConfig fessConfig = ComponentUtil.getFessConfig();
+        final int maxQueryLength = fessConfig.getQueryMaxLengthAsInteger().intValue();
+
+        stream(conditions.get(SearchRequestParams.AS_Q)).of(
+                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(
+                        q -> queryBuf.append(' ').append(q)));
+        stream(conditions.get(SearchRequestParams.AS_EPQ)).of(
+                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(
+                        q -> queryBuf.append(" \"").append(escape(q, "\"")).append('"')));
+        stream(conditions.get(SearchRequestParams.AS_OQ)).of(
+                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(
+                        oq -> split(oq, " ").get(
+                                s -> s.filter(StringUtil::isNotBlank).reduce(
+                                        (q1, q2) -> escape(q1, "(", ")") + " OR " + escape(q2, "(", ")"))).ifPresent(
+                                q -> queryBuf.append(" (").append(q).append(')'))));
+        stream(conditions.get(SearchRequestParams.AS_NQ)).of(
+                stream -> stream.filter(q -> StringUtil.isNotBlank(q) && q.length() <= maxQueryLength).forEach(
+                        eq -> {
+                            final String nq =
+                                    split(eq, " ").get(
+                                            s -> s.filter(StringUtil::isNotBlank).map(q -> "NOT " + q).collect(Collectors.joining(" ")));
+                            queryBuf.append(' ').append(nq);
+                        }));
+
     }
 
-    public QueryStringBuilder extraQueries(final String[] extraQueries) {
-        this.extraQueries = extraQueries;
-        return this;
+    private String escape(final String q, final String... values) {
+        String value = q;
+        for (String s : values) {
+            value = value.replace(s, "\\" + s);
+        }
+        return value;
     }
 
-    public QueryStringBuilder fields(final Map<String, String[]> fieldMap) {
-        this.fieldMap = fieldMap;
+    public QueryStringBuilder params(final SearchRequestParams params) {
+        this.params = params;
         return this;
     }
 }

+ 2 - 0
src/main/resources/app.xml

@@ -32,6 +32,8 @@
 	</component>
 	<component name="relatedQueryHelper" class="org.codelibs.fess.helper.RelatedQueryHelper">
 	</component>
+	<component name="queryStringBuilder" class="org.codelibs.fess.util.QueryStringBuilder" instance="prototype">
+	</component>
 	<component name="queryParser" class="org.apache.lucene.queryparser.ext.ExtendableQueryParser" instance="prototype">
 		<arg>org.codelibs.fess.Constants.DEFAULT_FIELD</arg>
 		<arg>

+ 4 - 0
src/main/resources/fess.xml

@@ -59,6 +59,10 @@
 			<arg>"searchOptions"</arg>
 			<arg>"searchOptions.jsp"</arg>
 		</postConstruct>
+		<postConstruct name="addDesignJspFileName">
+			<arg>"advance"</arg>
+			<arg>"advance.jsp"</arg>
+		</postConstruct>
 		<postConstruct name="addDesignJspFileName">
 			<arg>"help"</arg>
 			<arg>"help.jsp"</arg>

+ 6 - 0
src/main/resources/fess_label.properties

@@ -883,3 +883,9 @@ labels.esreq_button_upload=Send
 labels.facet_is_not_found=No match
 labels.doc_score=Score: 
 labels.development_mode_warning=Running as Development mode. For production use, please install a standalone elasticsearch server.
+labels.advance=Advance
+labels.advance_search_title=Advanced Search
+labels.advance_search_must_queries=All these words:
+labels.advance_search_phrase_query=This exact word or phrase:
+labels.advance_search_should_queries=Any of these words:
+labels.advance_search_not_queries=None of these words:

+ 6 - 0
src/main/resources/fess_label_en.properties

@@ -883,3 +883,9 @@ labels.esreq_button_upload=Send
 labels.facet_is_not_found=No match.
 labels.doc_score=Score: 
 labels.development_mode_warning=Running as Development mode. For production use, please install a standalone elasticsearch server.
+labels.advance=Advance
+labels.advance_search_title=Advanced Search
+labels.advance_search_must_queries=All these words:
+labels.advance_search_phrase_query=This exact word or phrase:
+labels.advance_search_should_queries=Any of these words:
+labels.advance_search_not_queries=None of these words:

+ 6 - 0
src/main/resources/fess_label_ja.properties

@@ -885,3 +885,9 @@ labels.esreq_button_upload=\u9001\u4fe1
 labels.facet_is_not_found=\u8a72\u5f53\u306a\u3057
 labels.doc_score=\u30b9\u30b3\u30a2: 
 labels.development_mode_warning=\u958b\u767a\u30e2\u30fc\u30c9\u3067\u8d77\u52d5\u3057\u3066\u3044\u307e\u3059\u3002\u672c\u904b\u7528\u74b0\u5883\u3067\u306fElasticsearch\u3092\u5225\u9014\u30a4\u30f3\u30b9\u30c8\u30fc\u30eb\u3057\u3066\u304f\u3060\u3055\u3044\u3002
+labels.advance=\u8a73\u7d30\u691c\u7d22
+labels.advance_search_title=\u8a73\u7d30\u691c\u7d22
+labels.advance_search_must_queries=\u3059\u3079\u3066\u306e\u5358\u8a9e\u3092\u542b\u3080:
+labels.advance_search_phrase_query=\u30d5\u30ec\u30fc\u30ba\u3092\u542b\u3080:
+labels.advance_search_should_queries=\u3044\u305a\u308c\u304b\u306e\u5358\u8a9e\u3092\u542b\u3080:
+labels.advance_search_not_queries=\u5358\u8a9e\u3092\u542b\u3081\u306a\u3044:

+ 8 - 0
src/main/webapp/WEB-INF/fe.tld

@@ -218,4 +218,12 @@
     <function-signature>java.lang.String sdh(java.lang.String)</function-signature>
     <example>${fe:sdh(doc.similar_docs_hash)}</example>
   </function>
+
+  <function>
+    <description>Concatenate strings.</description>
+    <name>join</name>
+    <function-class>org.codelibs.fess.taglib.FessFunctions</function-class>
+    <function-signature>java.lang.String join(java.lang.Object)</function-signature>
+    <example>${fe:join(values)}</example>
+  </function>
 </taglib>

+ 154 - 0
src/main/webapp/WEB-INF/orig/view/advance.jsp

@@ -0,0 +1,154 @@
+<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
+<!DOCTYPE html>
+<html>
+<head profile="http://a9.com/-/spec/opensearch/1.1/">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<title><la:message key="labels.search_title" /></title>
+<c:if test="${osddLink}">
+	<link rel="search" type="application/opensearchdescription+xml"
+		href="${fe:url('/osdd')}"
+		title="<la:message key="labels.index_osdd_title" />" />
+</c:if>
+<link href="${fe:url('/css/style-base.css')}" rel="stylesheet"
+	type="text/css" />
+<link href="${fe:url('/css/style.css')}" rel="stylesheet" type="text/css" />
+<link href="${fe:url('/css/font-awesome.min.css')}" rel="stylesheet"
+	type="text/css" />
+</head>
+<body>
+	<la:form styleClass="form-stacked" action="/search/" method="get"
+		styleId="searchForm">
+		${fe:facetForm()}${fe:geoForm()}
+		<nav class="navbar navbar-dark bg-inverse navbar-static-top pos-f-t">
+			<div id="content" class="container">
+				<ul class="nav navbar-nav pull-right">
+					<c:choose>
+						<c:when test="${!empty username && username != 'guest'}">
+							<li class="nav-item">
+								<div class="dropdown">
+									<a class="nav-link dropdown-toggle" data-toggle="dropdown"
+										href="#" role="button" aria-haspopup="true"
+										aria-expanded="false"> <i class="fa fa-user"></i>${username}
+									</a>
+									<div class="dropdown-menu" aria-labelledby="userMenu">
+										<c:if test="${editableUser == true}">
+											<la:link href="/profile" styleClass="dropdown-item">
+												<la:message key="labels.profile" />
+											</la:link>
+										</c:if>
+										<c:if test="${adminUser == true}">
+											<la:link href="/admin" styleClass="dropdown-item">
+												<la:message key="labels.administration" />
+											</la:link>
+										</c:if>
+										<la:link href="/logout/" styleClass="dropdown-item">
+											<la:message key="labels.logout" />
+										</la:link>
+									</div>
+								</div>
+							</li>
+						</c:when>
+						<c:when test="${ pageLoginLink }">
+							<li class="nav-item username"><la:link href="/login"
+									styleClass="nav-link" role="button" aria-haspopup="true"
+									aria-expanded="false">
+									<i class="fa fa-sign-in"></i>
+									<la:message key="labels.login" />
+								</la:link></li>
+						</c:when>
+					</c:choose>
+					<li class="nav-item"><la:link href="/help"
+							styleClass="nav-link help-link">
+							<i class="fa fa-question-circle"></i>
+							<la:message key="labels.index_help" />
+						</la:link></li>
+				</ul>
+			</div>
+		</nav>
+		<div class="container">
+			<div class="row content">
+				<div class="center-block center">
+					<h2>
+						<la:message key="labels.advance_search_title" />
+					</h2>
+					<div class="notification">${notification}</div>
+					<div>
+						<la:info id="msg" message="true">
+							<div class="alert alert-info">${msg}</div>
+						</la:info>
+						<la:errors header="errors.front_header"
+							footer="errors.front_footer" prefix="errors.front_prefix"
+							suffix="errors.front_suffix" />
+					</div>
+					<div class="centered col-lg-7 col-md-8 col-sm-8 col-xs-12">
+						<div class="form-group row">
+							<label for="as_q" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_must_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_q" name="as.q" value="${f:h(fe:join(as.q))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_epq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_phrase_query"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_epq" name="as.epq" value="${f:h(fe:join(as.epq))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_oq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_should_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_oq" name="as.oq" value="${f:h(fe:join(as.oq))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_nq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_not_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_nq" name="as.nq" value="${f:h(fe:join(as.nq))}">
+							</div>
+						</div>
+
+						<div class="clearfix searchButtonBox btn-group">
+							<button type="submit" name="search" id="searchButton" class="btn btn-primary">
+								<i class="fa fa-search"></i>
+								<la:message key="labels.index_form_search_btn" />
+							</button>
+						</div>
+					</div>
+				</div>
+			</div>
+			<jsp:include page="footer.jsp" />
+		</div>
+		<div id="searchOptions" class="control-options">
+			<div class="container">
+				<jsp:include page="searchOptions.jsp" />
+				<div>
+					<button type="button" class="btn btn-secondary" id="searchOptionsClearButton">
+						<la:message key="labels.search_options_clear" />
+					</button>
+					<button type="button" class="btn btn-secondary pull-right"
+						data-toggle="control-options" data-target="#searchOptions"
+						id="searchOptionsCloseButton">
+						<i class="fa fa-times-circle"></i>
+						<la:message key="labels.search_options_close" />
+					</button>
+				</div>
+			</div>
+		</div>
+	</la:form>
+	<input type="hidden" id="contextPath" value="${contextPath}" />
+	<script type="text/javascript"
+		src="${fe:url('/js/jquery-3.2.1.min.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/bootstrap.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/suggestor.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/index.js')}"></script>
+</body>
+</html>

+ 154 - 0
src/main/webapp/WEB-INF/view/advance.jsp

@@ -0,0 +1,154 @@
+<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
+<!DOCTYPE html>
+<html>
+<head profile="http://a9.com/-/spec/opensearch/1.1/">
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<meta http-equiv="X-UA-Compatible" content="IE=edge">
+<title><la:message key="labels.search_title" /></title>
+<c:if test="${osddLink}">
+	<link rel="search" type="application/opensearchdescription+xml"
+		href="${fe:url('/osdd')}"
+		title="<la:message key="labels.index_osdd_title" />" />
+</c:if>
+<link href="${fe:url('/css/style-base.css')}" rel="stylesheet"
+	type="text/css" />
+<link href="${fe:url('/css/style.css')}" rel="stylesheet" type="text/css" />
+<link href="${fe:url('/css/font-awesome.min.css')}" rel="stylesheet"
+	type="text/css" />
+</head>
+<body>
+	<la:form styleClass="form-stacked" action="/search/" method="get"
+		styleId="searchForm">
+		${fe:facetForm()}${fe:geoForm()}
+		<nav class="navbar navbar-dark bg-inverse navbar-static-top pos-f-t">
+			<div id="content" class="container">
+				<ul class="nav navbar-nav pull-right">
+					<c:choose>
+						<c:when test="${!empty username && username != 'guest'}">
+							<li class="nav-item">
+								<div class="dropdown">
+									<a class="nav-link dropdown-toggle" data-toggle="dropdown"
+										href="#" role="button" aria-haspopup="true"
+										aria-expanded="false"> <i class="fa fa-user"></i>${username}
+									</a>
+									<div class="dropdown-menu" aria-labelledby="userMenu">
+										<c:if test="${editableUser == true}">
+											<la:link href="/profile" styleClass="dropdown-item">
+												<la:message key="labels.profile" />
+											</la:link>
+										</c:if>
+										<c:if test="${adminUser == true}">
+											<la:link href="/admin" styleClass="dropdown-item">
+												<la:message key="labels.administration" />
+											</la:link>
+										</c:if>
+										<la:link href="/logout/" styleClass="dropdown-item">
+											<la:message key="labels.logout" />
+										</la:link>
+									</div>
+								</div>
+							</li>
+						</c:when>
+						<c:when test="${ pageLoginLink }">
+							<li class="nav-item username"><la:link href="/login"
+									styleClass="nav-link" role="button" aria-haspopup="true"
+									aria-expanded="false">
+									<i class="fa fa-sign-in"></i>
+									<la:message key="labels.login" />
+								</la:link></li>
+						</c:when>
+					</c:choose>
+					<li class="nav-item"><la:link href="/help"
+							styleClass="nav-link help-link">
+							<i class="fa fa-question-circle"></i>
+							<la:message key="labels.index_help" />
+						</la:link></li>
+				</ul>
+			</div>
+		</nav>
+		<div class="container">
+			<div class="row content">
+				<div class="center-block center">
+					<h2>
+						<la:message key="labels.advance_search_title" />
+					</h2>
+					<div class="notification">${notification}</div>
+					<div>
+						<la:info id="msg" message="true">
+							<div class="alert alert-info">${msg}</div>
+						</la:info>
+						<la:errors header="errors.front_header"
+							footer="errors.front_footer" prefix="errors.front_prefix"
+							suffix="errors.front_suffix" />
+					</div>
+					<div class="centered col-lg-7 col-md-8 col-sm-8 col-xs-12">
+						<div class="form-group row">
+							<label for="as_q" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_must_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_q" name="as.q" value="${f:h(fe:join(as.q))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_epq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_phrase_query"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_epq" name="as.epq" value="${f:h(fe:join(as.epq))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_oq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_should_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_oq" name="as.oq" value="${f:h(fe:join(as.oq))}">
+							</div>
+						</div>
+						<div class="form-group row">
+							<label for="as_nq" class="col-lg-4 col-md-6 col-sm-6 col-xs-6 col-form-label"><la:message
+									key="labels.advance_search_not_queries"
+								/></label>
+							<div class="col-lg-8 col-md-6 col-sm-6 col-xs-6">
+								<input class="form-control" type="text" id="as_nq" name="as.nq" value="${f:h(fe:join(as.nq))}">
+							</div>
+						</div>
+
+						<div class="clearfix searchButtonBox btn-group">
+							<button type="submit" name="search" id="searchButton" class="btn btn-primary">
+								<i class="fa fa-search"></i>
+								<la:message key="labels.index_form_search_btn" />
+							</button>
+						</div>
+					</div>
+				</div>
+			</div>
+			<jsp:include page="footer.jsp" />
+		</div>
+		<div id="searchOptions" class="control-options">
+			<div class="container">
+				<jsp:include page="searchOptions.jsp" />
+				<div>
+					<button type="button" class="btn btn-secondary" id="searchOptionsClearButton">
+						<la:message key="labels.search_options_clear" />
+					</button>
+					<button type="button" class="btn btn-secondary pull-right"
+						data-toggle="control-options" data-target="#searchOptions"
+						id="searchOptionsCloseButton">
+						<i class="fa fa-times-circle"></i>
+						<la:message key="labels.search_options_close" />
+					</button>
+				</div>
+			</div>
+		</div>
+	</la:form>
+	<input type="hidden" id="contextPath" value="${contextPath}" />
+	<script type="text/javascript"
+		src="${fe:url('/js/jquery-3.2.1.min.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/bootstrap.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/suggestor.js')}"></script>
+	<script type="text/javascript" src="${fe:url('/js/index.js')}"></script>
+</body>
+</html>

+ 4 - 0
src/main/webapp/WEB-INF/view/header.jsp

@@ -82,6 +82,10 @@
 					<i class="fa fa-search"></i>
 					<la:message key="labels.search" />
 				</button>
+				<la:link href="/search/advance?q=${f:u(q)}${fe:pagingQuery(null)}" styleClass="btn btn-primary">
+					<i class="fa fa-cog"></i>
+					<la:message key="labels.advance" />
+				</la:link>
 				<button type="button" class="btn btn-secondary pull-right"
 					data-toggle="control-options" data-target="#searchOptions"
 					id="searchOptionsCloseButton">

+ 155 - 0
src/test/java/org/codelibs/fess/util/QueryStringBuilderTest.java

@@ -0,0 +1,155 @@
+/*
+ * Copyright 2012-2018 CodeLibs Project and the Others.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
+ * either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+package org.codelibs.fess.util;
+
+import java.util.Collections;
+import java.util.Locale;
+import java.util.Map;
+
+import org.codelibs.fess.entity.FacetInfo;
+import org.codelibs.fess.entity.GeoInfo;
+import org.codelibs.fess.entity.SearchRequestParams;
+import org.codelibs.fess.unit.UnitFessTestCase;
+
+public class QueryStringBuilderTest extends UnitFessTestCase {
+
+    public void test_query() {
+        assertEquals("", getQuery("", new String[0], Collections.emptyMap(), Collections.emptyMap()));
+    }
+
+    public void test_conditions_q() {
+        final String k = "q";
+        assertEquals("", getAsQuery(Collections.singletonMap(k, new String[] { "" })));
+        assertEquals("aaa", getAsQuery(Collections.singletonMap(k, new String[] { "aaa" })));
+        assertEquals("aaa bbb", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb" })));
+        assertEquals("aaa bbb ccc", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb", "ccc" })));
+        assertEquals("aaa bbb", getAsQuery("111", Collections.singletonMap(k, new String[] { "aaa bbb" })));
+    }
+
+    public void test_conditions_epq() {
+        final String k = "epq";
+        assertEquals("", getAsQuery(Collections.singletonMap(k, new String[] { "" })));
+        assertEquals("\"aaa\"", getAsQuery(Collections.singletonMap(k, new String[] { "aaa" })));
+        assertEquals("\"aaa bbb\"", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb" })));
+        assertEquals("\"aaa bbb\" \"ccc\"", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb", "ccc" })));
+        assertEquals("\"a\\\"aa\"", getAsQuery(Collections.singletonMap(k, new String[] { "a\"aa" })));
+        assertEquals("\"aaa bbb\"", getAsQuery("111", Collections.singletonMap(k, new String[] { "aaa bbb" })));
+    }
+
+    public void test_conditions_oq() {
+        final String k = "oq";
+        assertEquals("", getAsQuery(Collections.singletonMap(k, new String[] { "" })));
+        assertEquals("(aaa)", getAsQuery(Collections.singletonMap(k, new String[] { "aaa" })));
+        assertEquals("(aaa OR bbb)", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb" })));
+        assertEquals("(aaa OR bbb) (ccc)", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb", "ccc" })));
+        assertEquals("(aaa OR bbb) (ccc OR ddd)", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb", "ccc ddd" })));
+        assertEquals("(aaa OR bbb)", getAsQuery("111", Collections.singletonMap(k, new String[] { "aaa bbb" })));
+    }
+
+    public void test_conditions_eq() {
+        final String k = "nq";
+        assertEquals("", getAsQuery(Collections.singletonMap(k, new String[] { "" })));
+        assertEquals("NOT aaa", getAsQuery(Collections.singletonMap(k, new String[] { "aaa" })));
+        assertEquals("NOT aaa NOT bbb", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb" })));
+        assertEquals("NOT aaa NOT bbb NOT ccc", getAsQuery(Collections.singletonMap(k, new String[] { "aaa bbb", "ccc" })));
+        assertEquals("NOT aaa NOT bbb", getAsQuery("111", Collections.singletonMap(k, new String[] { "aaa bbb" })));
+    }
+
+    private String getAsQuery(final String query, final Map<String, String[]> conditions) {
+        return getQuery(query, new String[0], Collections.emptyMap(), conditions);
+    }
+
+    private String getAsQuery(final Map<String, String[]> conditions) {
+        return getQuery("", new String[0], Collections.emptyMap(), conditions);
+    }
+
+    private String getQuery(final String query, final String[] extraQueries, final Map<String, String[]> fields,
+            final Map<String, String[]> conditions) {
+        return new QueryStringBuilder().params(new SearchRequestParams() {
+
+            @Override
+            public String getQuery() {
+                return query;
+            }
+
+            @Override
+            public Map<String, String[]> getFields() {
+                return fields;
+            }
+
+            @Override
+            public Map<String, String[]> getConditions() {
+                return conditions;
+            }
+
+            @Override
+            public String[] getLanguages() {
+                return null;
+            }
+
+            @Override
+            public GeoInfo getGeoInfo() {
+                return null;
+            }
+
+            @Override
+            public FacetInfo getFacetInfo() {
+                return null;
+            }
+
+            @Override
+            public String getSort() {
+                return null;
+            }
+
+            @Override
+            public int getStartPosition() {
+                return 0;
+            }
+
+            @Override
+            public int getPageSize() {
+                return 0;
+            }
+
+            @Override
+            public String[] getExtraQueries() {
+                return extraQueries;
+            }
+
+            @Override
+            public Object getAttribute(String name) {
+                return null;
+            }
+
+            @Override
+            public Locale getLocale() {
+                return null;
+            }
+
+            @Override
+            public SearchRequestType getType() {
+                return null;
+            }
+
+            @Override
+            public String getSimilarDocHash() {
+                return null;
+            }
+
+        }).build();
+    }
+}