From 3ef6c87384157b4c3df518ccaf666aa02b43fbe9 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 18:52:40 +0900 Subject: [PATCH 01/14] revert: drop the label kind tag implementation of user tags Reverts #3536 and #3540. User tags move to a separate per-user tag_type index instead of a label kind, so the label-kind implementation is removed. QueryContext.searchRequestType (and its setter call sites in QueryHelper and SearchEngineClient) is kept because #3546 uses it. --- .../config/es/fess_config_label_type.json | 5 +- src/main/config/openapi/v2/openapi-user.yaml | 153 --- .../fess/api/v2/SearchApiV2Manager.java | 11 - .../fess/api/v2/handlers/CsrfRequirement.java | 3 - .../api/v2/handlers/DocumentTagsHandler.java | 266 ----- .../api/v2/handlers/LoginRequirement.java | 4 +- .../fess/api/v2/handlers/SearchHandler.java | 105 +- .../fess/api/v2/handlers/UiConfigHandler.java | 2 - .../fess/app/service/LabelTypeService.java | 14 - .../app/web/admin/labeltype/CreateForm.java | 10 - .../AbstractFessFileTransformer.java | 2 - .../crawler/transformer/FessTransformer.java | 18 - .../transformer/FessXpathTransformer.java | 2 - .../ds/callback/IndexUpdateCallbackImpl.java | 10 - .../codelibs/fess/entity/QueryContext.java | 19 - .../codelibs/fess/helper/LabelTypeHelper.java | 175 +--- .../org/codelibs/fess/helper/TagHelper.java | 356 ------- .../org/codelibs/fess/job/UpdateLabelJob.java | 21 +- .../fess/mylasta/action/FessLabels.java | 12 - .../fess/mylasta/direction/FessConfig.java | 108 +-- .../config/bsbhv/BsLabelTypeBhv.java | 1 - .../config/bsentity/BsLabelType.java | 17 - .../config/bsentity/dbmeta/LabelTypeDbm.java | 8 - .../config/cbean/bs/BsLabelTypeCB.java | 4 - .../config/cbean/ca/bs/BsLabelTypeCA.java | 130 --- .../config/cbean/cq/bs/BsLabelTypeCQ.java | 224 ----- .../opensearch/config/exentity/LabelType.java | 23 +- .../fess/query/FuzzyQueryCommand.java | 5 - .../fess/query/PhraseQueryCommand.java | 9 - .../fess/query/PrefixQueryCommand.java | 5 - .../org/codelibs/fess/query/QueryCommand.java | 66 -- .../codelibs/fess/query/QueryFieldConfig.java | 4 - .../codelibs/fess/query/TermQueryCommand.java | 3 - .../fess/query/TermRangeQueryCommand.java | 5 - .../fess/query/WildcardQueryCommand.java | 4 - .../org/codelibs/fess/util/ComponentUtil.java | 11 - src/main/resources/app.xml | 2 - src/main/resources/fess_api.xml | 1 - src/main/resources/fess_config.properties | 10 +- .../resources/fess_indices/_aws/fess/doc.json | 3 - .../fess_indices/_cloud/fess/doc.json | 3 - src/main/resources/fess_indices/fess/doc.json | 3 - .../fess_config.label_type/label_type.json | 3 - src/main/resources/fess_label.properties | 4 - src/main/resources/fess_label_de.properties | 4 - src/main/resources/fess_label_en.properties | 4 - src/main/resources/fess_label_es.properties | 4 - src/main/resources/fess_label_fr.properties | 4 - src/main/resources/fess_label_hi.properties | 4 - src/main/resources/fess_label_id.properties | 4 - src/main/resources/fess_label_it.properties | 4 - src/main/resources/fess_label_ja.properties | 4 - src/main/resources/fess_label_ko.properties | 4 - src/main/resources/fess_label_nl.properties | 4 - src/main/resources/fess_label_pl.properties | 4 - .../resources/fess_label_pt_BR.properties | 4 - src/main/resources/fess_label_ru.properties | 4 - src/main/resources/fess_label_tr.properties | 4 - .../resources/fess_label_zh_CN.properties | 4 - .../resources/fess_label_zh_TW.properties | 4 - .../labeltype/admin_labeltype_details.jsp | 9 - .../admin/labeltype/admin_labeltype_edit.jsp | 15 - src/main/webapp/themes/bootstrap/README.md | 2 +- .../webapp/themes/bootstrap/assets/api.js | 23 - .../webapp/themes/bootstrap/assets/search.js | 338 +------ .../webapp/themes/bootstrap/assets/styles.css | 7 - .../themes/bootstrap/i18n/messages.de.json | 7 - .../themes/bootstrap/i18n/messages.en.json | 7 - .../themes/bootstrap/i18n/messages.es.json | 7 - .../themes/bootstrap/i18n/messages.fr.json | 7 - .../themes/bootstrap/i18n/messages.hi.json | 7 - .../themes/bootstrap/i18n/messages.id.json | 7 - .../themes/bootstrap/i18n/messages.it.json | 7 - .../themes/bootstrap/i18n/messages.ja.json | 7 - .../themes/bootstrap/i18n/messages.ko.json | 7 - .../themes/bootstrap/i18n/messages.nl.json | 7 - .../themes/bootstrap/i18n/messages.pl.json | 7 - .../themes/bootstrap/i18n/messages.pt-BR.json | 7 - .../themes/bootstrap/i18n/messages.ru.json | 7 - .../themes/bootstrap/i18n/messages.tr.json | 7 - .../themes/bootstrap/i18n/messages.zh-CN.json | 7 - .../themes/bootstrap/i18n/messages.zh-TW.json | 7 - src/main/webapp/themes/bootstrap/theme.yml | 2 +- .../api/v2/SearchApiV2ManagerCsrfTest.java | 51 - .../SearchApiV2ManagerLoginRequiredTest.java | 21 +- .../fess/api/v2/SearchApiV2ManagerTest.java | 48 - .../api/v2/SearchApiV2ManagerTestSupport.java | 2 - .../CsrfRequirementCompleteCoverageTest.java | 24 +- .../v2/handlers/DocumentTagsHandlerTest.java | 916 ------------------ .../LoginRequirementCompleteCoverageTest.java | 3 +- .../api/v2/handlers/SearchHandlerTest.java | 225 ----- .../api/v2/handlers/UiConfigHandlerTest.java | 39 - .../fess/helper/LabelTypeHelperTest.java | 72 -- .../codelibs/fess/helper/TagHelperTest.java | 255 ----- .../fess/query/MatchAllQueryCommandTest.java | 5 - .../codelibs/fess/query/QueryCommandTest.java | 5 - .../QueryFieldConfigSetBasedLookupTest.java | 5 - .../fess/query/QueryFieldConfigTest.java | 22 - .../codelibs/fess/query/QueryTestBase.java | 5 - .../fess/query/TagQueryRestrictionTest.java | 217 ----- .../fess/query/TermQueryCommandTest.java | 5 - src/test/js/themes/bootstrap/api.test.js | 31 - .../js/themes/bootstrap/user-tags.test.js | 522 ---------- 103 files changed, 66 insertions(+), 4823 deletions(-) delete mode 100644 src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java delete mode 100644 src/main/java/org/codelibs/fess/helper/TagHelper.java delete mode 100644 src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java delete mode 100644 src/test/java/org/codelibs/fess/helper/TagHelperTest.java delete mode 100644 src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java delete mode 100644 src/test/js/themes/bootstrap/user-tags.test.js diff --git a/src/main/config/es/fess_config_label_type.json b/src/main/config/es/fess_config_label_type.json index b3daff8835..65abd1438c 100644 --- a/src/main/config/es/fess_config_label_type.json +++ b/src/main/config/es/fess_config_label_type.json @@ -16,9 +16,6 @@ "includedPaths" : { "type" : "keyword" }, - "kind" : { - "type" : "keyword" - }, "name" : { "type" : "keyword" }, @@ -65,4 +62,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/main/config/openapi/v2/openapi-user.yaml b/src/main/config/openapi/v2/openapi-user.yaml index 97fa78f4a1..b48dfb1a66 100644 --- a/src/main/config/openapi/v2/openapi-user.yaml +++ b/src/main/config/openapi/v2/openapi-user.yaml @@ -71,8 +71,6 @@ tags: description: Monitoring operations - name: favorite description: Favorite operations - - name: tag - description: Tags that users add to documents - name: auth description: Authentication and session operations - name: ui @@ -1088,96 +1086,6 @@ paths: '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalServerError' } - /documents/{docId}/tags: - parameters: - - { $ref: '#/components/parameters/DocId' } - get: - tags: [tag] - summary: List the tags of a document - description: |- - Lists the tags of the document that the caller can see. A tag is a - label of the kind `tag`: its name is the tag name, its included paths - list the tagged URLs and its permissions decide who can see it. - Answers `400` while `user.tag.enabled` is false, and `404` when the - caller cannot see the document. - operationId: documentTagsGetV2 - responses: - '200': - description: Tags returned. - content: - application/json: - schema: - $ref: '#/components/schemas/DocumentTagsResponse' - '400': { $ref: '#/components/responses/BadRequest' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } - '405': { $ref: '#/components/responses/MethodNotAllowed' } - '500': { $ref: '#/components/responses/InternalServerError' } - post: - tags: [tag] - summary: Tag a document - description: |- - Tags the document's URL for the logged-in user; an access token does - not stand in for a login. The name is NFKC-normalized, whitespace is - collapsed and it is trimmed. When a tag of the name exists, the URL is - added to its included paths and the user to its permissions; otherwise - a tag that only the user can see is created. Tagging again answers - `added: false`. `user.tag.max.document.tags` limits the tags of one - document. - operationId: documentTagsPostV2 - parameters: - - { $ref: '#/components/parameters/CsrfHeader' } - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DocumentTagPostRequest' - responses: - '200': - description: Document tagged, or already tagged by the user. - content: - application/json: - schema: - $ref: '#/components/schemas/DocumentTagsResponse' - '400': { $ref: '#/components/responses/BadRequest' } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } - '405': { $ref: '#/components/responses/MethodNotAllowed' } - '413': { $ref: '#/components/responses/PayloadTooLarge' } - '415': { $ref: '#/components/responses/UnsupportedMediaType' } - '500': { $ref: '#/components/responses/InternalServerError' } - delete: - tags: [tag] - summary: Remove the caller from a tag - description: |- - Removes the logged-in user from the permissions of the tag. The tag is - deleted, and removed from the documents, when no permission of a user, - a group or a role is left. Answers `403` when the user is not in the - permissions of the tag. - operationId: documentTagsDeleteV2 - parameters: - - { $ref: '#/components/parameters/CsrfHeader' } - - name: value - in: query - required: true - description: The tag value. - schema: { type: string } - responses: - '200': - description: The user was removed from the tag. - content: - application/json: - schema: - $ref: '#/components/schemas/DocumentTagsResponse' - '400': { $ref: '#/components/responses/BadRequest' } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } - '405': { $ref: '#/components/responses/MethodNotAllowed' } - '500': { $ref: '#/components/responses/InternalServerError' } - /cache/{docId}: get: tags: [cache] @@ -1552,14 +1460,6 @@ components: description: Stored as the string `"true"`/`"false"`, not a JSON boolean. click_count: { type: integer, format: int64 } favorite_count: { type: integer, format: int64 } - tags: - type: array - description: |- - The tags of the document that the caller can see. Present only while - `user.tag.enabled` is true and the document has such tags. The `tag` - index field itself is not returned. - items: - $ref: '#/components/schemas/DocumentTag' similar_docs_count: type: integer format: int64 @@ -1777,11 +1677,6 @@ components: description: |- `true` when `GET /documents/export` is available (`api.search.export`). - user_tag: - type: boolean - description: |- - `true` when users can see and add tags - (`/documents/{docId}/tags`, `user.tag.enabled`). popular_word: { type: boolean } suggest_search_log: { type: boolean } suggest_documents: { type: boolean } @@ -2323,54 +2218,6 @@ components: True when the favorite was previously recorded (idempotent re-POST). Absent on the first POST that newly records the favorite. - # --- Tags -------------------------------------------------------- - DocumentTag: - type: object - required: [value, name, mine] - properties: - value: - type: string - description: The value of the label of the tag; filter results with `fields.tag=`. - name: { type: string } - mine: - type: boolean - description: '`true` when the caller is in the permissions of the tag.' - - DocumentTagPostRequest: - type: object - required: [name] - properties: - name: - type: string - description: |- - 1 to `user.tag.name.max.length` (default 50) characters, without a - control or format character. - - DocumentTagsResponse: - type: object - required: [response] - properties: - response: - allOf: - - $ref: '#/components/schemas/EnvelopeMeta' - - type: object - required: [doc_id, addable, tags] - properties: - doc_id: { type: string } - addable: - type: boolean - description: '`true` when the caller is logged in.' - added: - type: boolean - description: POST only. `false` when the user had already tagged the document. - removed: - type: boolean - description: DELETE only. - tags: - type: array - items: - $ref: '#/components/schemas/DocumentTag' - # --- Cache ------------------------------------------------------- CacheResponse: type: object diff --git a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java index 52e3626f35..76d1cc2989 100644 --- a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java +++ b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java @@ -27,7 +27,6 @@ import org.codelibs.fess.api.v2.handlers.ChatSessionClearHandler; import org.codelibs.fess.api.v2.handlers.ChatStreamHandler; import org.codelibs.fess.api.v2.handlers.ClickHandler; -import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler; import org.codelibs.fess.api.v2.handlers.CsrfRequirement; import org.codelibs.fess.api.v2.handlers.ExportSearchHandler; import org.codelibs.fess.api.v2.handlers.FavoriteGetHandler; @@ -103,10 +102,6 @@ public class SearchApiV2Manager extends BaseApiManager { @Resource protected FavoritePostHandler favoritePostHandler; - /** Handles {@code GET/POST/DELETE /api/v2/documents/{id}/tags} (list, add and remove user tags). */ - @Resource - protected DocumentTagsHandler documentTagsHandler; - /** Handles {@code GET /api/v2/favorites} (list favorited doc ids in a previously issued search result). */ @Resource protected FavoritesListHandler favoritesListHandler; @@ -307,12 +302,6 @@ public void process(final HttpServletRequest request, final HttpServletResponse } return; } - // "/documents/tags" leaves no doc id between the two ends, like "/documents/favorite" above. - if (sub.startsWith("/documents/") && sub.endsWith("/tags") && sub.length() > "/documents/".length() + "/tags".length()) { - final String docId = sub.substring("/documents/".length(), sub.length() - "/tags".length()); - documentTagsHandler.handle(request, response, docId); - return; - } if (sub.startsWith("/cache/")) { final String docId = sub.substring("/cache/".length()); cacheHandler.handle(request, response, docId); diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java b/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java index 446d0e3660..6fee937f56 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/CsrfRequirement.java @@ -89,9 +89,6 @@ public boolean requiresCsrf(final String subPath, final String method) { if (subPath.startsWith("/documents/") && subPath.endsWith("/favorite")) { return true; } - if (subPath.startsWith("/documents/") && subPath.endsWith("/tags")) { - return true; - } if ("/chat".equals(subPath)) { return true; } diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java deleted file mode 100644 index 9129b77dca..0000000000 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java +++ /dev/null @@ -1,266 +0,0 @@ -/* - * Copyright 2012-2025 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.api.v2.handlers; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.api.v2.V2ErrorCode; -import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; -import org.codelibs.fess.helper.LabelTypeHelper.LabelTypeItem; -import org.codelibs.fess.helper.TagHelper; -import org.codelibs.fess.helper.TagHelper.AddResult; -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.DocumentUtil; -import org.dbflute.optional.OptionalThing; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - -/** - * Handles {@code GET}, {@code POST} and {@code DELETE /api/v2/documents/{docId}/tags}. - * - *

A tag is a label type of the kind {@code tag}: its name is the tag name, its included paths list the tagged - * URLs and its permissions list who can see it.

- * - * - * - *

The document is resolved through the caller's roles, so a document the caller cannot search cannot be - * tagged. Writes need a logged-in user; an access token does not stand in for one.

- */ -public class DocumentTagsHandler { - - private static final Logger logger = LogManager.getLogger(DocumentTagsHandler.class); - - /** The request body has one short string. */ - private static final int MAX_BODY_BYTES = 1024; - - /** - * Default constructor used by the DI container. The handler holds no per-request state. - */ - public DocumentTagsHandler() { - // no-op - } - - /** A failure that is answered with an error envelope. */ - private static class TagRequestException extends Exception { - private static final long serialVersionUID = 1L; - - private final V2ErrorCode code; - - TagRequestException(final V2ErrorCode code, final String message) { - super(message, null, false, false); - this.code = code; - } - } - - /** - * Processes one {@code /api/v2/documents/{docId}/tags} request. - * - * @param req the incoming HTTP request - * @param res the HTTP response to write to - * @param docId the document id extracted from the URL path - * @throws IOException if writing the envelope fails - */ - public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException { - final String method = req.getMethod() == null ? StringUtil.EMPTY : req.getMethod().toUpperCase(Locale.ROOT); - if (!"GET".equals(method) && !"POST".equals(method) && !"DELETE".equals(method)) { - res.setHeader("Allow", "GET, POST, DELETE"); - ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.METHOD_NOT_ALLOWED, "method not allowed"); - return; - } - final String context = "/api/v2/documents/" + docId + "/tags " + method; - try { - if (!ComponentUtil.getV2DocIdValidator().isValid(docId)) { - throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "invalid doc_id"); - } - final TagHelper tagHelper = ComponentUtil.getTagHelper(); - if (!tagHelper.isEnabled()) { - throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "tag feature is not available"); - } - final String userId = getUserBean().map(FessUserBean::getUserId).orElse(null); - if (!"GET".equals(method) && StringUtil.isBlank(userId)) { - throw new TagRequestException(V2ErrorCode.AUTH_REQUIRED, "login required"); - } - final String url = getDocumentUrl(docId); - - final Map payload = new LinkedHashMap<>(); - payload.put("doc_id", docId); - switch (method) { - case "POST" -> payload.put("added", addTag(req, tagHelper, userId, url)); - case "DELETE" -> payload.put("removed", removeTag(req, tagHelper, userId)); - default -> { - // GET lists the tags only - } - } - payload.put("addable", StringUtil.isNotBlank(userId)); - payload.put("tags", buildTags(req, tagHelper, userId, url)); - ComponentUtil.getV2EnvelopeWriter().writeSuccess(res, payload); - } catch (final TagRequestException e) { - ComponentUtil.getV2EnvelopeWriter().writeError(res, e.code, e.getMessage()); - } catch (final Exception e) { - ComponentUtil.getV2EnvelopeWriter().writeInternalError(res, e, logger, context); - } - } - - /** - * Validates the request body of a POST and tags the URL. - * - * @return true if the URL was tagged, false if the user had already tagged it - */ - private boolean addTag(final HttpServletRequest req, final TagHelper tagHelper, final String userId, final String url) - throws TagRequestException, IOException { - final Map body; - try { - body = ComponentUtil.getV2JsonBody().read(req, MAX_BODY_BYTES); - } catch (final V2JsonBody.PayloadTooLargeException e) { - throw new TagRequestException(V2ErrorCode.PAYLOAD_TOO_LARGE, e.getMessage()); - } catch (final V2JsonBody.UnsupportedMediaTypeException e) { - throw new TagRequestException(V2ErrorCode.UNSUPPORTED_MEDIA_TYPE, e.getMessage()); - } catch (final V2JsonBody.MalformedJsonException e) { - throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, e.getMessage()); - } - final Object name = body.get("name"); - final String normalizedName = name instanceof final String nameValue ? tagHelper.normalizeName(nameValue) : null; - if (normalizedName == null) { - throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, - "invalid tag name: enter 1 to " + tagHelper.getNameMaxLength() + " characters"); - } - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final AddResult result = tagHelper.addTag(userId, url, normalizedName); - switch (result) { - case TOO_MANY_TAGS -> throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, - "too many tags: a document can have up to " + fessConfig.getUserTagMaxDocumentTagsAsInteger() + " tags"); - case TOO_MANY_LABELS -> throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, - "no more tags can be created: the number of labels reached page.labeltype.max.fetch.size"); - case ALREADY_ADDED -> { - return false; - } - default -> { - updateDocuments(() -> tagHelper.addTagToDocuments(url, tagHelper.toValue(normalizedName)), url); - return true; - } - } - } - - /** - * Removes the user from the permissions of the tag of the DELETE request. - * - * @return true if the user was removed - */ - private boolean removeTag(final HttpServletRequest req, final TagHelper tagHelper, final String userId) throws TagRequestException { - final String value = req.getParameter("value"); - final LabelTypeItem item = StringUtil.isBlank(value) ? null - : getTagItemList(req).stream().filter(i -> value.equals(i.getValue())).findFirst().orElse(null); - if (item == null) { - throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "invalid tag value"); - } - if (!tagHelper.isMine(item, userId)) { - throw new TagRequestException(V2ErrorCode.FORBIDDEN, "the tag was not added by the user"); - } - if (tagHelper.removeTag(userId, value)) { - updateDocuments(() -> tagHelper.removeTagFromDocuments(value), value); - } - return true; - } - - /** - * Updates the tag field of the indexed documents. The label type is already stored, so a failure here is - * logged and the next crawl or the label updater job brings the documents up to date. - */ - private void updateDocuments(final Runnable update, final String target) { - try { - update.run(); - } catch (final Exception e) { - logger.warn("Failed to update the tag field of the documents: target={}", target, e); - } - } - - private List> buildTags(final HttpServletRequest req, final TagHelper tagHelper, final String userId, - final String url) { - final List> tags = new ArrayList<>(); - for (final LabelTypeItem item : getTagItemList(req)) { - if (item.getUrlSet().contains(url)) { - final Map tag = new LinkedHashMap<>(); - tag.put("value", item.getValue()); - tag.put("name", item.getLabel()); - tag.put("mine", tagHelper.isMine(item, userId)); - tags.add(tag); - } - } - return tags; - } - - /** - * Returns the URL of the document, resolved through the caller's roles. - */ - private String getDocumentUrl(final String docId) throws TagRequestException { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final Map doc = getDocument(docId, new String[] { fessConfig.getIndexFieldUrl() }); - final String url = doc == null ? null : DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class); - if (StringUtil.isBlank(url)) { - throw new TagRequestException(V2ErrorCode.NOT_FOUND, "doc not found: " + docId); - } - return url; - } - - /** - * Fetches a document with the caller's role filter. Exposed as a seam for unit tests. - * - * @param docId the document id - * @param fields the fields to fetch - * @return the document, or null if the caller cannot see it - */ - protected Map getDocument(final String docId, final String[] fields) { - return ComponentUtil.getSearchHelper().getDocumentByDocId(docId, fields, getUserBean()).orElse(null); - } - - /** - * Returns the tags that the caller can see. Exposed as a seam for unit tests. - * - * @param req the request - * @return the visible tags - */ - protected List getTagItemList(final HttpServletRequest req) { - final Locale locale = req.getLocale() == null ? Locale.ROOT : req.getLocale(); - return ComponentUtil.getLabelTypeHelper().getTagItemList(SearchRequestType.JSON, locale); - } - - /** - * Returns the user of the login session. An access token is not a login session. Exposed as a seam for unit - * tests. - * - * @return the logged-in user - */ - protected OptionalThing getUserBean() { - return ComponentUtil.getRequestManager().findUserBean(FessUserBean.class); - } -} diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java b/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java index 2db65f09fd..86f765511b 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java @@ -73,7 +73,7 @@ public boolean requiresLogin(final String subPath) { // login_required flag the page needs in order to show the login form at all. case "/auth/me", "/auth/login", "/auth/logout", "/ui/config" -> false; // Secure default: everything else — search, scroll, suggest, labels, popular words, - // related queries/content, chat, click, favorites, tags, search history, cache and any endpoint added later + // related queries/content, chat, click, favorites, search history, cache and any endpoint added later // — is served only to an authenticated user. default -> true; }; @@ -85,7 +85,7 @@ public boolean requiresLogin(final String subPath) { * *

The accepted set is the endpoints on which a token already decides what the caller * sees: they resolve the caller's roles through {@code RoleQueryHelper}, which adds the - * token's permissions to an API request. Endpoints that act for a signed-in user (favorites, tags, + * token's permissions to an API request. Endpoints that act for a signed-in user (favorites, * search history, click logging, password change), generative endpoints (chat) and the cache view, which * checks the session itself, keep requiring a login.

* diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java index 12c371a882..399231caf3 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java @@ -17,11 +17,8 @@ import java.io.IOException; import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; import org.apache.logging.log4j.LogManager; @@ -31,10 +28,8 @@ import org.codelibs.fess.api.v2.V2EnvelopeWriter; import org.codelibs.fess.api.v2.V2ErrorCode; import org.codelibs.fess.entity.SearchRenderData; -import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; import org.codelibs.fess.exception.InvalidQueryException; import org.codelibs.fess.exception.ResultOffsetExceededException; -import org.codelibs.fess.helper.LabelTypeHelper.LabelTypeItem; import org.codelibs.fess.helper.RelatedContentHelper; import org.codelibs.fess.helper.RelatedQueryHelper; import org.codelibs.fess.helper.SearchHelper; @@ -120,11 +115,10 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r ComponentUtil.getV2EnvelopeWriter().writeSuccess(response, payload); return; } - final OptionalThing userBean = getSavedUserBean(); - final Map payload = - buildPayload(params.getQuery(), data, getTagItemMap(request), userBean.map(FessUserBean::getUserId).orElse(null)); + final Map payload = buildPayload(params.getQuery(), data); // Evaluated per search: a user whose group and role permissions are still loading or // failed sees fewer results. + final OptionalThing userBean = getSavedUserBean(); payload.put("permission_state", ComponentUtil.getV2UserPayloads().permissionState(userBean.isPresent() ? userBean.get() : null)); ComponentUtil.getV2EnvelopeWriter().writeSuccess(response, payload); @@ -175,12 +169,9 @@ protected OptionalThing getSavedUserBean() { * * @param query the original {@code q} parameter (may be {@code null}) * @param data the populated render data returned by the search helper - * @param tagItemMap the tags that the caller can see, by value - * @param userId the logged-in user, or null * @return the ordered payload map ready for envelope serialization */ - private Map buildPayload(final String query, final SearchRenderData data, final Map tagItemMap, - final String userId) { + private Map buildPayload(final String query, final SearchRenderData data) { final RelatedQueryHelper relatedQueryHelper = ComponentUtil.getRelatedQueryHelper(); final RelatedContentHelper relatedContentHelper = ComponentUtil.getRelatedContentHelper(); @@ -207,11 +198,11 @@ private Map buildPayload(final String query, final SearchRenderD payload.put("requested_time", data.getRequestedTime()); payload.put("related_query", relatedQueryHelper.getRelatedQueries(query)); payload.put("related_contents", relatedContentHelper.getRelatedContents(query)); - payload.put("data", filterDocuments(data.getDocumentItems(), tagItemMap, userId)); + payload.put("data", filterDocuments(data.getDocumentItems())); final FacetResponse facetResponse = data.getFacetResponse(); if (facetResponse != null && facetResponse.hasFacetResponse()) { - payload.put("facet_field", buildFacetField(facetResponse, tagItemMap)); + payload.put("facet_field", buildFacetField(facetResponse)); payload.put("facet_query", buildFacetQuery(facetResponse)); } return payload; @@ -224,47 +215,23 @@ private Map buildPayload(final String query, final SearchRenderD *

Drops blank keys and {@code null} values — matching v1's behavior so the * wire payload size stays stable across versions.

* - *

The {@code tag} field is not an API response field: the tags of the label types that the caller - * can see are returned as {@code tags}, a list of {@code {value, name, mine}}.

- * * @param docs the raw document items from {@link SearchRenderData} - * @param tagItemMap the tags that the caller can see, by value - * @param userId the logged-in user, or null * @return a new list of filtered, order-preserved document maps */ - List> filterDocuments(final List> docs, final Map tagItemMap, - final String userId) { + private List> filterDocuments(final List> docs) { if (docs == null || docs.isEmpty()) { return new ArrayList<>(0); } final QueryFieldConfig cfg = ComponentUtil.getQueryFieldConfig(); - // never returned as is, even when query.additional.api.response.fields lists it - final String tagField = ComponentUtil.getFessConfig().getIndexFieldTag(); final List> out = new ArrayList<>(docs.size()); for (final Map doc : docs) { final Map filtered = new LinkedHashMap<>(); for (final Map.Entry e : doc.entrySet()) { final String name = e.getKey(); - if (StringUtil.isNotBlank(name) && e.getValue() != null && cfg.isApiResponseField(name) && !tagField.equals(name)) { + if (StringUtil.isNotBlank(name) && e.getValue() != null && cfg.isApiResponseField(name)) { filtered.put(name, e.getValue()); } } - if (!tagItemMap.isEmpty()) { - final List> tags = new ArrayList<>(); - for (final String value : toStringList(doc.get(tagField))) { - final LabelTypeItem item = tagItemMap.get(value); - if (item != null) { - final Map tag = new LinkedHashMap<>(); - tag.put("value", value); - tag.put("name", item.getLabel()); - tag.put("mine", ComponentUtil.getTagHelper().isMine(item, userId)); - tags.add(tag); - } - } - if (!tags.isEmpty()) { - filtered.put("tags", tags); - } - } out.add(filtered); } return out; @@ -281,18 +248,6 @@ List> filterDocuments(final List> docs, * @return a list of {@code {name, result:[{value, count}]}} maps */ List> buildFacetField(final FacetResponse facetResponse) { - return buildFacetField(facetResponse, Collections.emptyMap()); - } - - /** - * Builds the {@code facet_field} array. The values of the {@code tag} facet are limited to the tags that the - * caller can see, so a tag is not revealed by its count, and each carries the tag name as {@code label}. - * - * @param facetResponse the populated facet response (never {@code null}) - * @param tagItemMap the tags that the caller can see, by value - * @return a list of {@code {name, result:[{value, count}]}} maps - */ - List> buildFacetField(final FacetResponse facetResponse, final Map tagItemMap) { final List fields = facetResponse.getFieldList(); if (fields == null || fields.isEmpty()) { return new ArrayList<>(0); @@ -302,18 +257,10 @@ List> buildFacetField(final FacetResponse facetResponse, fin final Map entry = new LinkedHashMap<>(); entry.put("name", field.getName()); final List> results = new ArrayList<>(); - final boolean tagField = ComponentUtil.getFessConfig().getIndexFieldTag().equals(field.getName()); for (final Map.Entry vc : field.getValueCountMap().entrySet()) { - final LabelTypeItem tagItem = tagField ? tagItemMap.get(vc.getKey()) : null; - if (tagField && tagItem == null) { - continue; - } final Map result = new LinkedHashMap<>(); result.put("value", vc.getKey()); result.put("count", vc.getValue()); - if (tagItem != null) { - result.put("label", tagItem.getLabel()); - } results.add(result); } entry.put("result", results); @@ -322,44 +269,6 @@ List> buildFacetField(final FacetResponse facetResponse, fin return out; } - /** - * Returns the tags that the caller can see by value, or an empty map while user tags are disabled. - * Exposed as a seam for unit tests. - * - * @param request the request - * @return the visible tags by value - */ - protected Map getTagItemMap(final HttpServletRequest request) { - try { - if (!ComponentUtil.getFessConfig().isUserTagEnabled()) { - return Collections.emptyMap(); - } - final Locale locale = request.getLocale() == null ? Locale.ROOT : request.getLocale(); - final Map map = new LinkedHashMap<>(); - ComponentUtil.getLabelTypeHelper() - .getTagItemList(SearchRequestType.JSON, locale) - .forEach(item -> map.put(item.getValue(), item)); - return map; - } catch (final Exception e) { - logger.debug("Failed to resolve the tags; no tags are returned.", e); - return Collections.emptyMap(); - } - } - - private static List toStringList(final Object value) { - final List list = new ArrayList<>(); - if (value instanceof final Collection collection) { - collection.forEach(v -> list.add(String.valueOf(v))); - } else if (value instanceof final Object[] array) { - for (final Object v : array) { - list.add(String.valueOf(v)); - } - } else if (value != null) { - list.add(value.toString()); - } - return list; - } - /** * Builds the {@code facet_query} array — a flat list of value/count pairs * for each configured facet query. diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java index 9f8c9fddbf..e8fb0d9b13 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java @@ -423,8 +423,6 @@ public void handle(final HttpServletRequest req, final HttpServletResponse res) features.put("search_history", cfg.isSearchHistoryEnabled() && searchLogEnabled); // search_export: whether GET /api/v2/documents/export is available, so the theme shows the export links. features.put("search_export", cfg.isApiSearchExport()); - // user_tag: whether users can see and add tags (GET/POST/DELETE /api/v2/documents/{doc_id}/tags). - features.put("user_tag", cfg.isUserTagEnabled()); features.put("popular_word", cfg.isWebApiPopularWord()); features.put("suggest_search_log", cfg.isSuggestSearchLog()); features.put("suggest_documents", cfg.isSuggestDocuments()); diff --git a/src/main/java/org/codelibs/fess/app/service/LabelTypeService.java b/src/main/java/org/codelibs/fess/app/service/LabelTypeService.java index 936226b1ab..6c90c00fa8 100644 --- a/src/main/java/org/codelibs/fess/app/service/LabelTypeService.java +++ b/src/main/java/org/codelibs/fess/app/service/LabelTypeService.java @@ -125,20 +125,6 @@ public List getLabelTypeList() { }); } - /** - * Get the label type of a value. - * - * @param value The value of the label type. - * @return The label type, or empty if no label type has the value. - */ - public OptionalEntity getLabelTypeByValue(final String value) { - return labelTypeBhv.selectList(cb -> { - cb.query().setValue_Term(value); - cb.query().addOrderBy_CreatedTime_Asc(); - cb.fetchFirst(1); - }).stream().findFirst().map(OptionalEntity::of).orElseGet(OptionalEntity::empty); - } - /** * Store a label type. * diff --git a/src/main/java/org/codelibs/fess/app/web/admin/labeltype/CreateForm.java b/src/main/java/org/codelibs/fess/app/web/admin/labeltype/CreateForm.java index b13229ac2c..a101a1291c 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/labeltype/CreateForm.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/labeltype/CreateForm.java @@ -16,7 +16,6 @@ package org.codelibs.fess.app.web.admin.labeltype; import org.codelibs.fess.app.web.CrudMode; -import org.codelibs.fess.opensearch.config.exentity.LabelType; import org.codelibs.fess.util.ComponentUtil; import org.codelibs.fess.validation.CustomSize; import org.lastaflute.web.validation.Required; @@ -59,14 +58,6 @@ public CreateForm() { @Pattern(regexp = "^[a-zA-Z0-9_]+$") public String value; - /** - * The kind of the label type: label (assigned to documents whose URL matches the paths) or tag (a tag that users - * add from the search screen; the included paths are the tagged URLs). A blank kind is a label. - */ - @Size(max = 10) - @Pattern(regexp = "^(label|tag)$") - public String kind; - /** * The paths to include for this label type. */ @@ -117,7 +108,6 @@ public CreateForm() { public void initialize() { crudMode = CrudMode.CREATE; sortOrder = 0; - kind = LabelType.KIND_LABEL; createdBy = ComponentUtil.getSystemHelper().getUsername(); createdTime = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); permissions = ComponentUtil.getFessConfig().getSearchDefaultDisplayPermission(); diff --git a/src/main/java/org/codelibs/fess/crawler/transformer/AbstractFessFileTransformer.java b/src/main/java/org/codelibs/fess/crawler/transformer/AbstractFessFileTransformer.java index 9ae23eb582..9d0dd9b9ca 100644 --- a/src/main/java/org/codelibs/fess/crawler/transformer/AbstractFessFileTransformer.java +++ b/src/main/java/org/codelibs/fess/crawler/transformer/AbstractFessFileTransformer.java @@ -362,8 +362,6 @@ protected Map generateData(final ResponseData responseData) { // label: labelType final LabelTypeHelper labelTypeHelper = ComponentUtil.getLabelTypeHelper(); putResultDataBody(dataMap, fessConfig.getIndexFieldLabel(), labelTypeHelper.getMatchedLabelValueSet(url)); - // tag: labelType of the kind tag - putTagField(dataMap, url); // role: roleType final List roleTypeList = getRoleTypes(responseData); stream(crawlingConfig.getPermissions()).of(stream -> stream.forEach(p -> roleTypeList.add(p))); diff --git a/src/main/java/org/codelibs/fess/crawler/transformer/FessTransformer.java b/src/main/java/org/codelibs/fess/crawler/transformer/FessTransformer.java index 1e0ab07828..bc4fed8b26 100644 --- a/src/main/java/org/codelibs/fess/crawler/transformer/FessTransformer.java +++ b/src/main/java/org/codelibs/fess/crawler/transformer/FessTransformer.java @@ -23,7 +23,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.Map; -import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.logging.log4j.Logger; @@ -182,23 +181,6 @@ default void putResultDataBody(final Map dataMap, final String k } } - /** - * Puts the values of the tags whose included paths list the URL into the tag field while user tags are enabled. - * A tag is a label type of the kind tag, so the tags come only from the label types. - * - * @param dataMap the data map to modify - * @param url the URL of the document - */ - default void putTagField(final Map dataMap, final String url) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - if (fessConfig.isUserTagEnabled()) { - final Set tagSet = ComponentUtil.getLabelTypeHelper().getMatchedTagValueSet(url); - if (!tagSet.isEmpty()) { - dataMap.put(fessConfig.getIndexFieldTag(), tagSet.toArray(new String[tagSet.size()])); - } - } - } - /** * Puts data into the result data map after processing it through a template script. * The template is evaluated using the specified script engine with the value and context. diff --git a/src/main/java/org/codelibs/fess/crawler/transformer/FessXpathTransformer.java b/src/main/java/org/codelibs/fess/crawler/transformer/FessXpathTransformer.java index 30b1a57e18..7509d60b9c 100644 --- a/src/main/java/org/codelibs/fess/crawler/transformer/FessXpathTransformer.java +++ b/src/main/java/org/codelibs/fess/crawler/transformer/FessXpathTransformer.java @@ -593,8 +593,6 @@ && isValidCanonicalUrl(responseData.getUrl(), canonicalUrl)) { putResultDataBody(dataMap, fessConfig.getIndexFieldBoost(), crawlingConfig.getDocumentBoost()); // label: labelType putResultDataBody(dataMap, fessConfig.getIndexFieldLabel(), labelTypeHelper.getMatchedLabelValueSet(url)); - // tag: labelType of the kind tag - putTagField(dataMap, url); // role: roleType final List roleTypeList = new ArrayList<>(); stream(crawlingConfig.getPermissions()).of(stream -> stream.forEach(p -> roleTypeList.add(p))); diff --git a/src/main/java/org/codelibs/fess/ds/callback/IndexUpdateCallbackImpl.java b/src/main/java/org/codelibs/fess/ds/callback/IndexUpdateCallbackImpl.java index e4da0ddbad..b65da4f5e3 100644 --- a/src/main/java/org/codelibs/fess/ds/callback/IndexUpdateCallbackImpl.java +++ b/src/main/java/org/codelibs/fess/ds/callback/IndexUpdateCallbackImpl.java @@ -132,16 +132,6 @@ public void store(final DataStoreParams paramMap, final Map data addFavoriteCountField(dataMap, url, fessConfig.getIndexFieldFavoriteCount()); } - if (fessConfig.isUserTagEnabled()) { - // the tags are users' and come only from the label types of the kind tag - final Set tagSet = ComponentUtil.getLabelTypeHelper().getMatchedTagValueSet(url); - if (tagSet.isEmpty()) { - dataMap.remove(fessConfig.getIndexFieldTag()); - } else { - dataMap.put(fessConfig.getIndexFieldTag(), tagSet.toArray(new String[tagSet.size()])); - } - } - final Set matchedLabelSet = ComponentUtil.getLabelTypeHelper().getMatchedLabelValueSet(url); if (!matchedLabelSet.isEmpty()) { final Set newLabelSet = new HashSet<>(); diff --git a/src/main/java/org/codelibs/fess/entity/QueryContext.java b/src/main/java/org/codelibs/fess/entity/QueryContext.java index 373c682245..367173e86a 100644 --- a/src/main/java/org/codelibs/fess/entity/QueryContext.java +++ b/src/main/java/org/codelibs/fess/entity/QueryContext.java @@ -74,9 +74,6 @@ public class QueryContext { /** The type of the search request, or null when the query is not built for a search request. */ protected SearchRequestType searchRequestType = null; - /** The values of the tags that the caller can see, resolved on first use. */ - protected Set visibleTagValueSet = null; - /** * Constructs a new QueryContext with the specified query string. * Processes special query prefixes (allinurl:, allintitle:) and initializes @@ -291,20 +288,4 @@ public SearchRequestType getSearchRequestType() { public void setSearchRequestType(final SearchRequestType searchRequestType) { this.searchRequestType = searchRequestType; } - - /** - * Gets the values of the tags that the caller can see, as resolved for this query. - * @return The visible tag values, or null if they are not resolved yet. - */ - public Set getVisibleTagValueSet() { - return visibleTagValueSet; - } - - /** - * Sets the values of the tags that the caller can see. - * @param visibleTagValueSet The visible tag values. - */ - public void setVisibleTagValueSet(final Set visibleTagValueSet) { - this.visibleTagValueSet = visibleTagValueSet; - } } diff --git a/src/main/java/org/codelibs/fess/helper/LabelTypeHelper.java b/src/main/java/org/codelibs/fess/helper/LabelTypeHelper.java index 4a64995da9..d85b3e64b3 100644 --- a/src/main/java/org/codelibs/fess/helper/LabelTypeHelper.java +++ b/src/main/java/org/codelibs/fess/helper/LabelTypeHelper.java @@ -21,7 +21,6 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; import java.util.Map; @@ -101,35 +100,11 @@ protected void buildLabelTypeItems(final List labelTypeList) { item.setPermissions(labelType.getPermissions()); item.setVirtualHost(labelType.getVirtualHost()); item.setLocale(labelType.getLocale()); - item.setTag(labelType.isTagKind()); - if (item.isTag()) { - // the included paths of a tag are the tagged URLs, one per line, matched exactly - item.setUrlSet(toUrlSet(labelType.getIncludedPaths())); - } itemList.add(item); } labelTypeItemList = itemList; } - /** - * Splits the included paths of a tag into its URLs. - * - * @param includedPaths The included paths, one URL per line. - * @return The URLs. - */ - public static Set toUrlSet(final String includedPaths) { - final Set urlSet = new LinkedHashSet<>(); - if (StringUtil.isNotBlank(includedPaths)) { - for (final String line : includedPaths.split("\n")) { - final String url = line.trim(); - if (!url.isEmpty()) { - urlSet.add(url); - } - } - } - return urlSet; - } - /** * Returns a list of label type items. * @@ -141,95 +116,13 @@ public List> getLabelTypeItemList(final SearchRequestType se } /** - * Returns a list of label type items. Label types of the kind {@value LabelType#KIND_TAG} are not included. + * Returns a list of label type items. * * @param searchRequestType The search request type. * @param requestLocale The request locale. * @return A list of label type items. */ public List> getLabelTypeItemList(final SearchRequestType searchRequestType, final Locale requestLocale) { - return getVisibleItemList(searchRequestType, requestLocale, false); - } - - /** - * Returns the tags that the current user can see: the label types of the kind {@value LabelType#KIND_TAG} whose - * permissions match the user. The same virtual host, locale and permission rules as - * {@link #getLabelTypeItemList(SearchRequestType, Locale)} apply. - * - * @param searchRequestType The search request type. - * @param requestLocale The request locale. - * @return The visible tags. - */ - public List getTagItemList(final SearchRequestType searchRequestType, final Locale requestLocale) { - return getVisibleItems(searchRequestType, requestLocale, true); - } - - /** - * Returns the values of the tags that the current user can see. - * - * @param searchRequestType The search request type. - * @param requestLocale The request locale. - * @return A set of tag values. - */ - public Set getTagValueSet(final SearchRequestType searchRequestType, final Locale requestLocale) { - return getTagItemList(searchRequestType, requestLocale).stream() - .map(LabelTypeItem::getValue) - .collect(Collectors.toCollection(LinkedHashSet::new)); - } - - /** - * Returns the values of the tags whose included paths list the given URL. A tag matches the exact URL, not a - * regular expression. - * - * @param url The document URL. - * @return A set of tag values. - */ - public Set getMatchedTagValueSet(final String url) { - if (labelTypeItemList == null) { - init(); - } - if (url == null) { - return Collections.emptySet(); - } - final Set valueSet = new LinkedHashSet<>(); - for (final LabelTypeItem item : labelTypeItemList) { - if (item.isTag() && item.getUrlSet().contains(url)) { - valueSet.add(item.getValue()); - } - } - return valueSet; - } - - /** - * Returns the label type items of one kind that the current user can see, as maps with the label and the value. - * - * @param searchRequestType The search request type. - * @param requestLocale The request locale. - * @param tag true for the label types of the kind {@value LabelType#KIND_TAG}, false for the others - * @return A list of maps with the label and the value of each label type. - */ - protected List> getVisibleItemList(final SearchRequestType searchRequestType, final Locale requestLocale, - final boolean tag) { - final List> itemList = new ArrayList<>(); - for (final LabelTypeItem item : getVisibleItems(searchRequestType, requestLocale, tag)) { - final Map map = new HashMap<>(2); - map.put(Constants.ITEM_LABEL, item.getLabel()); - map.put(Constants.ITEM_VALUE, item.getValue()); - itemList.add(map); - } - return itemList; - } - - /** - * Returns the label type items of one kind that the current user can see. - * - * @param searchRequestType The search request type. - * @param requestLocale The request locale. - * @param tag true for the label types of the kind {@value LabelType#KIND_TAG}, false for the others - * @return The visible label type items. - */ - protected List getVisibleItems(final SearchRequestType searchRequestType, final Locale requestLocale, - final boolean tag) { if (labelTypeItemList == null) { init(); } @@ -237,22 +130,23 @@ protected List getVisibleItems(final SearchRequestType searchRequ final String virtualHostKey = ComponentUtil.getVirtualHostHelper().getVirtualHostKey(); final List labelList; if (StringUtil.isBlank(virtualHostKey)) { - labelList = labelTypeItemList.stream() - .filter(item -> item.isTag() == tag && matchLocale(requestLocale, item.getLocale())) - .collect(Collectors.toList()); + labelList = + labelTypeItemList.stream().filter(item -> matchLocale(requestLocale, item.getLocale())).collect(Collectors.toList()); } else { labelList = labelTypeItemList.stream() - .filter(item -> item.isTag() == tag && matchLocale(requestLocale, item.getLocale()) - && virtualHostKey.equals(item.getVirtualHost())) + .filter(item -> matchLocale(requestLocale, item.getLocale()) && virtualHostKey.equals(item.getVirtualHost())) .collect(Collectors.toList()); } - final List itemList = new ArrayList<>(); + final List> itemList = new ArrayList<>(); final Set roleSet = ComponentUtil.getRoleQueryHelper().build(searchRequestType); if (roleSet.isEmpty()) { for (final LabelTypeItem item : labelList) { if (item.getPermissions().length == 0) { - itemList.add(item); + final Map map = new HashMap<>(2); + map.put(Constants.ITEM_LABEL, item.getLabel()); + map.put(Constants.ITEM_VALUE, item.getValue()); + itemList.add(map); } } } else { @@ -260,7 +154,10 @@ protected List getVisibleItems(final SearchRequestType searchRequ final Set permissions = stream(item.getPermissions()).get(stream -> stream.collect(Collectors.toSet())); for (final String roleValue : roleSet) { if (permissions.contains(roleValue)) { - itemList.add(item); + final Map map = new HashMap<>(2); + map.put(Constants.ITEM_LABEL, item.getLabel()); + map.put(Constants.ITEM_VALUE, item.getValue()); + itemList.add(map); break; } } @@ -324,10 +221,6 @@ public Set getMatchedLabelValueSet(final String path) { protected void buildLabelTypePatternList(final List labelTypeList) { final List list = new ArrayList<>(); for (final LabelType labelType : labelTypeList) { - if (labelType.isTagKind()) { - // the included paths of a tag are exact URLs, not patterns: see getMatchedTagValueSet - continue; - } final String includedPaths = labelType.getIncludedPaths(); final String excludedPaths = labelType.getExcludedPaths(); if (StringUtil.isNotBlank(includedPaths) || StringUtil.isNotBlank(excludedPaths)) { @@ -345,7 +238,7 @@ protected void buildLabelTypePatternList(final List labelTypeList) { /** * An item of a label type. */ - public static class LabelTypeItem { + protected static class LabelTypeItem { /** * Default constructor. */ @@ -363,10 +256,6 @@ public LabelTypeItem() { private Locale locale; - private boolean tag; - - private Set urlSet = Collections.emptySet(); - /** * Returns the label. * @@ -456,42 +345,6 @@ public Locale getLocale() { public void setLocale(final Locale locale) { this.locale = locale; } - - /** - * Returns whether this label type defines user tags. - * - * @return True if the kind of the label type is {@value LabelType#KIND_TAG}. - */ - public boolean isTag() { - return tag; - } - - /** - * Sets whether this label type defines user tags. - * - * @param tag True if the kind of the label type is {@value LabelType#KIND_TAG}. - */ - public void setTag(final boolean tag) { - this.tag = tag; - } - - /** - * Returns the tagged URLs of a tag. - * - * @return The URLs; empty for a label. - */ - public Set getUrlSet() { - return urlSet; - } - - /** - * Sets the tagged URLs of a tag. - * - * @param urlSet The URLs. - */ - public void setUrlSet(final Set urlSet) { - this.urlSet = urlSet; - } } /** diff --git a/src/main/java/org/codelibs/fess/helper/TagHelper.java b/src/main/java/org/codelibs/fess/helper/TagHelper.java deleted file mode 100644 index b458c74551..0000000000 --- a/src/main/java/org/codelibs/fess/helper/TagHelper.java +++ /dev/null @@ -1,356 +0,0 @@ -/* - * Copyright 2012-2025 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.helper; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.text.Normalizer; -import java.util.ArrayList; -import java.util.Collections; -import java.util.HexFormat; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.regex.Pattern; - -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.app.service.LabelTypeService; -import org.codelibs.fess.mylasta.direction.FessConfig; -import org.codelibs.fess.opensearch.config.exentity.LabelType; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.fesen.opensearch.index.query.QueryBuilders; -import org.codelibs.fesen.opensearch.script.Script; -import org.codelibs.fesen.opensearch.script.ScriptType; - -/** - * Helper for the tags that users add to documents. - * - *

A tag is a label type of the kind {@code tag}: its name is the tag name, its value is derived from the name, its - * included paths list the tagged URLs (one per line, matched exactly) and its permissions list who can see it. A user - * who adds a tag is added to its permissions, and a user in its permissions can remove it from themselves; the label - * type is deleted when no permission is left. Administrators manage tags on the label admin screen like any label.

- * - *

The values of the tags of a URL are indexed in the {@code tag} field, by the crawler from the label types and - * immediately by an update of the indexed documents when a user adds or removes a tag.

- */ -public class TagHelper { - private static final Logger logger = LogManager.getLogger(TagHelper.class); - - private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); - - /** The number of attempts to store a tag that another request changed at the same time. */ - private static final int MAX_STORE_ATTEMPTS = 3; - - /** The result of adding a tag. */ - public enum AddResult { - /** The URL was tagged or the user was added to the tag. */ - ADDED, - /** The user had already tagged the URL. */ - ALREADY_ADDED, - /** The document has the maximum number of tags. */ - TOO_MANY_TAGS, - /** No more label types can be loaded. */ - TOO_MANY_LABELS - } - - /** - * Default constructor. - */ - public TagHelper() { - // do nothing - } - - /** - * Returns whether users can tag documents. - * - * @return true if {@code user.tag.enabled} is true - */ - public boolean isEnabled() { - return ComponentUtil.getFessConfig().isUserTagEnabled(); - } - - /** - * Returns the label type value of a tag name: the SHA-256 of the name in hex. It satisfies the label value rule - * ({@code [a-zA-Z0-9_]}, at most 100 characters) for any name, and the same name always gets the same value. - * - * @param name the normalized tag name - * @return the tag value - */ - public String toValue(final String name) { - try { - final MessageDigest digest = MessageDigest.getInstance("SHA-256"); - return HexFormat.of().formatHex(digest.digest(name.getBytes(StandardCharsets.UTF_8))); - } catch (final NoSuchAlgorithmException e) { - throw new IllegalStateException("SHA-256 is not available.", e); - } - } - - /** - * Normalizes a tag name that a user entered. The name is NFKC-normalized, whitespace is collapsed into one space - * and the name is trimmed. - * - * @param name the entered name - * @return the normalized name, or null if the name is blank, too long, or has a control character or a format - * character such as a zero-width space or a bidirectional override - */ - public String normalizeName(final String name) { - if (name == null) { - return null; - } - final String normalized = WHITESPACE_PATTERN.matcher(Normalizer.normalize(name, Normalizer.Form.NFKC)).replaceAll(" ").trim(); - if (normalized.isEmpty() || normalized.codePointCount(0, normalized.length()) > getNameMaxLength()) { - return null; - } - for (int i = 0; i < normalized.length(); i++) { - final char c = normalized.charAt(i); - if (Character.isISOControl(c) || Character.getType(c) == Character.FORMAT) { - return null; - } - } - return normalized; - } - - /** - * Returns the maximum length of a tag name. - * - * @return the maximum number of characters - */ - public int getNameMaxLength() { - return ComponentUtil.getFessConfig().getUserTagNameMaxLengthAsInteger(); - } - - /** - * Returns the permission value of a user, such as {@code 1alice}. - * - * @param userId the user name - * @return the encoded permission - */ - public String toUserPermission(final String userId) { - return ComponentUtil.getFessConfig().getRoleSearchUserPrefix() + userId; - } - - /** - * Tags the documents of a URL for a user. When a tag of the name exists, the URL is added to its included paths - * and the user to its permissions; otherwise a tag is created that only the user can see. - * - * @param userId the user name - * @param url the URL of the document - * @param name the normalized tag name - * @return the result - */ - public synchronized AddResult addTag(final String userId, final String url, final String name) { - final String value = toValue(name); - final String permission = toUserPermission(userId); - final LabelTypeService labelTypeService = getLabelTypeService(); - for (int attempt = 1;; attempt++) { - final LabelType labelType = labelTypeService.getLabelTypeByValue(value).orElse(null); - final AddResult result; - final LabelType entity; - if (labelType == null) { - if (labelTypeService.getLabelTypeList().size() >= ComponentUtil.getFessConfig().getPageLabeltypeMaxFetchSizeAsInteger()) { - return AddResult.TOO_MANY_LABELS; - } - if (countTags(url) >= getMaxDocumentTags()) { - return AddResult.TOO_MANY_TAGS; - } - entity = createTag(userId, url, name, value, permission); - result = AddResult.ADDED; - } else { - if (!labelType.isTagKind()) { - throw new IllegalStateException("The value of a tag is used by a label: " + value); - } - final Set urlSet = LabelTypeHelper.toUrlSet(labelType.getIncludedPaths()); - final List permissions = toList(labelType.getPermissions()); - if (urlSet.contains(url) && permissions.contains(permission)) { - return AddResult.ALREADY_ADDED; - } - if (!urlSet.contains(url) && countTags(url) >= getMaxDocumentTags()) { - return AddResult.TOO_MANY_TAGS; - } - urlSet.add(url); - if (!permissions.contains(permission)) { - permissions.add(permission); - } - labelType.setIncludedPaths(String.join("\n", urlSet)); - labelType.setPermissions(permissions.toArray(new String[permissions.size()])); - labelType.setUpdatedBy(userId); - labelType.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); - entity = labelType; - result = AddResult.ADDED; - } - try { - labelTypeService.store(entity); - return result; - } catch (final RuntimeException e) { - // another request changed the same tag; read it again - if (attempt >= MAX_STORE_ATTEMPTS) { - throw e; - } - logger.debug("Retrying to store the tag: value={}, attempt={}", value, attempt, e); - } - } - } - - /** - * Removes a user from the permissions of a tag. The tag is deleted when no permission, of a user, a group or a - * role, is left. - * - * @param userId the user name - * @param value the tag value - * @return true if the tag was deleted, false if it was kept for its other permissions - */ - public synchronized boolean removeTag(final String userId, final String value) { - final String permission = toUserPermission(userId); - final LabelTypeService labelTypeService = getLabelTypeService(); - for (int attempt = 1;; attempt++) { - final LabelType labelType = labelTypeService.getLabelTypeByValue(value).orElse(null); - if (labelType == null || !labelType.isTagKind()) { - return false; - } - final List permissions = toList(labelType.getPermissions()); - if (!permissions.remove(permission)) { - return false; - } - try { - if (permissions.isEmpty()) { - labelTypeService.delete(labelType); - return true; - } - labelType.setPermissions(permissions.toArray(new String[permissions.size()])); - labelType.setUpdatedBy(userId); - labelType.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); - labelTypeService.store(labelType); - return false; - } catch (final RuntimeException e) { - if (attempt >= MAX_STORE_ATTEMPTS) { - throw e; - } - logger.debug("Retrying to remove the user from the tag: value={}, attempt={}", value, attempt, e); - } - } - } - - /** - * Returns whether a user is in the permissions of a tag. - * - * @param item the tag - * @param userId the user name, or null for an anonymous user - * @return true if the user added the tag - */ - public boolean isMine(final LabelTypeHelper.LabelTypeItem item, final String userId) { - return StringUtil.isNotBlank(userId) && toList(item.getPermissions()).contains(toUserPermission(userId)); - } - - /** - * Adds a tag value to the {@code tag} field of every indexed document of a URL. - * - * @param url the URL - * @param value the tag value - */ - public void addTagToDocuments(final String url, final String value) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String field = fessConfig.getIndexFieldTag(); - updateDocuments(QueryBuilders.termQuery(fessConfig.getIndexFieldUrl(), url), - "if (ctx._source." + field + " == null) { ctx._source." + field + " = []; } else if (!(ctx._source." + field - + " instanceof List)) { ctx._source." + field + " = [ctx._source." + field + "]; } if (!ctx._source." + field - + ".contains(params.value)) { ctx._source." + field + ".add(params.value); }", - value); - } - - /** - * Removes a tag value from the {@code tag} field of every indexed document. - * - * @param value the tag value - */ - public void removeTagFromDocuments(final String value) { - final String field = ComponentUtil.getFessConfig().getIndexFieldTag(); - updateDocuments(QueryBuilders.termQuery(field, value), - "if (ctx._source." + field + " instanceof List) { ctx._source." + field - + ".removeIf(v -> v == params.value); } else if (ctx._source." + field + " == params.value) { ctx._source.remove('" - + field + "'); }", - value); - } - - private void updateDocuments(final org.codelibs.fesen.opensearch.index.query.QueryBuilder query, final String code, - final String value) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final Script script = new Script(ScriptType.INLINE, Script.DEFAULT_SCRIPT_LANG, code, Map.of("value", value)); - final long updated = ComponentUtil.getSearchEngineClient() - .updateByQuery(fessConfig.getIndexDocumentUpdateIndex(), - option -> option.setQuery(query).setFetchSource(new String[] { fessConfig.getIndexFieldUrl() }, null), - (builder, hit) -> builder.setScript(script)); - if (logger.isDebugEnabled()) { - logger.debug("Updated the tag of documents: value={}, documents={}", value, updated); - } - } - - /** - * Creates a tag that only its creator can see. - */ - protected LabelType createTag(final String userId, final String url, final String name, final String value, final String permission) { - final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); - final LabelType labelType = new LabelType(); - labelType.setName(name); - labelType.setValue(value); - labelType.setKind(LabelType.KIND_TAG); - labelType.setIncludedPaths(url); - labelType.setPermissions(new String[] { permission }); - // a label with a virtual host is shown only on that host, and one without only when no virtual host matches - final String virtualHostKey = ComponentUtil.getVirtualHostHelper().getVirtualHostKey(); - labelType.setVirtualHost(StringUtil.isBlank(virtualHostKey) ? StringUtil.EMPTY : virtualHostKey); - labelType.setSortOrder(0); - labelType.setCreatedBy(userId); - labelType.setCreatedTime(now); - labelType.setUpdatedBy(userId); - labelType.setUpdatedTime(now); - return labelType; - } - - /** - * Returns the number of tags of a URL. - */ - protected int countTags(final String url) { - return ComponentUtil.getLabelTypeHelper().getMatchedTagValueSet(url).size(); - } - - /** - * Returns the maximum number of tags on one document. - */ - protected int getMaxDocumentTags() { - return ComponentUtil.getFessConfig().getUserTagMaxDocumentTagsAsInteger(); - } - - /** - * Returns the label type service. - * - * @return the service - */ - protected LabelTypeService getLabelTypeService() { - return ComponentUtil.getComponent(LabelTypeService.class); - } - - private static List toList(final String[] values) { - final List list = new ArrayList<>(); - if (values != null) { - Collections.addAll(list, values); - } - return new ArrayList<>(new LinkedHashSet<>(list)); - } -} diff --git a/src/main/java/org/codelibs/fess/job/UpdateLabelJob.java b/src/main/java/org/codelibs/fess/job/UpdateLabelJob.java index 72d1eb173c..85409b26ea 100644 --- a/src/main/java/org/codelibs/fess/job/UpdateLabelJob.java +++ b/src/main/java/org/codelibs/fess/job/UpdateLabelJob.java @@ -52,8 +52,7 @@ public UpdateLabelJob() { /** * Executes the label update job. - * Processes documents in the search index and updates their label fields, and their tag fields while user tags - * are enabled. + * Processes documents in the search index and updates their label fields. * * @return execution result message */ @@ -77,22 +76,8 @@ public String execute() { final String url = DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class); if (StringUtil.isNotBlank(url)) { final Set labelSet = labelTypeHelper.getMatchedLabelValueSet(url); - final StringBuilder code = new StringBuilder(); - code.append("ctx._source.") - .append(fessConfig.getIndexFieldLabel()) - .append("=new String[]{") - .append(labelSet.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(","))) - .append('}'); - if (fessConfig.isUserTagEnabled()) { - // tags are label types of the kind tag, whose values are [a-zA-Z0-9_] like any label value - final Set tagSet = labelTypeHelper.getMatchedTagValueSet(url); - code.append(";ctx._source.") - .append(fessConfig.getIndexFieldTag()) - .append("=new String[]{") - .append(tagSet.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(","))) - .append('}'); - } - final Script script = languageHelper.createScript(doc, code.toString()); + final Script script = languageHelper.createScript(doc, "ctx._source." + fessConfig.getIndexFieldLabel() + + "=new String[]{" + labelSet.stream().map(s -> "\"" + s + "\"").collect(Collectors.joining(",")) + "}"); return builder.setScript(script); } } catch (final Exception e) { diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java index b6ead77a3f..b4105daed1 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java @@ -1568,18 +1568,6 @@ public class FessLabels extends UserMessages { /** The key of the message: Value */ public static final String LABELS_labeltype_value = "{labels.labeltype_value}"; - /** The key of the message: Kind */ - public static final String LABELS_labeltype_kind = "{labels.labeltype_kind}"; - - /** The key of the message: Label */ - public static final String LABELS_labeltype_kind_label = "{labels.labeltype_kind_label}"; - - /** The key of the message: Tag */ - public static final String LABELS_labeltype_kind_tag = "{labels.labeltype_kind_tag}"; - - /** The key of the message: A Tag is a tag that users add from the search screen: Name is the tag name, Included Paths lists the tagged URLs (one URL per line, exact match), and Permissions decides who can see it. A user who adds a tag is added to its Permissions. */ - public static final String LABELS_labeltype_kind_help = "{labels.labeltype_kind_help}"; - /** The key of the message: Included Paths */ public static final String LABELS_labeltype_included_paths = "{labels.labeltype_included_paths}"; diff --git a/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java b/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java index 53d5742466..0577a927db 100644 --- a/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java +++ b/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java @@ -717,9 +717,6 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. label */ String INDEX_FIELD_LABEL = "index.field.label"; - /** The key of the configuration. e.g. tag */ - String INDEX_FIELD_TAG = "index.field.tag"; - /** The key of the configuration. e.g. mimetype */ String INDEX_FIELD_MIMETYPE = "index.field.mimetype"; @@ -837,7 +834,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. */ String INDEX_DICTIONARY_PREFIX = "index.dictionary.prefix"; - /** The key of the configuration. e.g. lang,role,label,anchor,virtual_host,tag */ + /** The key of the configuration. e.g. lang,role,label,anchor,virtual_host */ String INDEX_ADMIN_ARRAY_FIELDS = "index.admin.array.fields"; /** The key of the configuration. e.g. expires,created,timestamp,last_modified */ @@ -1361,15 +1358,6 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. 10 */ String SEARCH_HISTORY_SIZE = "search.history.size"; - /** The key of the configuration. e.g. false */ - String USER_TAG_ENABLED = "user.tag.enabled"; - - /** The key of the configuration. e.g. 50 */ - String USER_TAG_NAME_MAX_LENGTH = "user.tag.name.max.length"; - - /** The key of the configuration. e.g. 100 */ - String USER_TAG_MAX_DOCUMENT_TAGS = "user.tag.max.document.tags"; - /** The key of the configuration. e.g. 10000 */ String FORM_ADMIN_MAX_INPUT_SIZE = "form.admin.max.input.size"; @@ -4835,14 +4823,6 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ String getIndexFieldLabel(); - /** - * Get the value for the key 'index.field.tag'.
- * The value is, e.g. tag
- * comment: Field name for the tags of the document in the index: the values of the labels of the kind "tag" whose included paths list the document URL. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - */ - String getIndexFieldTag(); - /** * Get the value for the key 'index.field.mimetype'.
* The value is, e.g. mimetype
@@ -5241,7 +5221,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** * Get the value for the key 'index.admin.array.fields'.
- * The value is, e.g. lang,role,label,anchor,virtual_host,tag
+ * The value is, e.g. lang,role,label,anchor,virtual_host
* comment: Array-type fields for admin in the index. * @return The value of found property. (NotNull: if not found, exception but basically no way) */ @@ -7198,56 +7178,6 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ Integer getSearchHistorySizeAsInteger(); - /** - * Get the value for the key 'user.tag.enabled'.
- * The value is, e.g. false
- * comment: Whether logged-in users can tag documents from the search screen. A tag is a label of the kind "tag": its name is the tag name, its included paths list the tagged URLs and its permissions list the users who added it. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - */ - String getUserTagEnabled(); - - /** - * Is the property for the key 'user.tag.enabled' true?
- * The value is, e.g. false
- * comment: Whether logged-in users can tag documents from the search screen. A tag is a label of the kind "tag": its name is the tag name, its included paths list the tagged URLs and its permissions list the users who added it. - * @return The determination, true or false. (if not found, exception but basically no way) - */ - boolean isUserTagEnabled(); - - /** - * Get the value for the key 'user.tag.name.max.length'.
- * The value is, e.g. 50
- * comment: Maximum length of a tag name. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - */ - String getUserTagNameMaxLength(); - - /** - * Get the value for the key 'user.tag.name.max.length' as {@link Integer}.
- * The value is, e.g. 50
- * comment: Maximum length of a tag name. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - * @throws NumberFormatException When the property is not integer. - */ - Integer getUserTagNameMaxLengthAsInteger(); - - /** - * Get the value for the key 'user.tag.max.document.tags'.
- * The value is, e.g. 100
- * comment: Maximum number of tags on one document. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - */ - String getUserTagMaxDocumentTags(); - - /** - * Get the value for the key 'user.tag.max.document.tags' as {@link Integer}.
- * The value is, e.g. 100
- * comment: Maximum number of tags on one document. - * @return The value of found property. (NotNull: if not found, exception but basically no way) - * @throws NumberFormatException When the property is not integer. - */ - Integer getUserTagMaxDocumentTagsAsInteger(); - /** * Get the value for the key 'form.admin.max.input.size'.
* The value is, e.g. 10000
@@ -12341,10 +12271,6 @@ public String getIndexFieldLabel() { return get(FessConfig.INDEX_FIELD_LABEL); } - public String getIndexFieldTag() { - return get(FessConfig.INDEX_FIELD_TAG); - } - public String getIndexFieldMimetype() { return get(FessConfig.INDEX_FIELD_MIMETYPE); } @@ -13413,30 +13339,6 @@ public Integer getSearchHistorySizeAsInteger() { return getAsInteger(FessConfig.SEARCH_HISTORY_SIZE); } - public String getUserTagEnabled() { - return get(FessConfig.USER_TAG_ENABLED); - } - - public boolean isUserTagEnabled() { - return is(FessConfig.USER_TAG_ENABLED); - } - - public String getUserTagNameMaxLength() { - return get(FessConfig.USER_TAG_NAME_MAX_LENGTH); - } - - public Integer getUserTagNameMaxLengthAsInteger() { - return getAsInteger(FessConfig.USER_TAG_NAME_MAX_LENGTH); - } - - public String getUserTagMaxDocumentTags() { - return get(FessConfig.USER_TAG_MAX_DOCUMENT_TAGS); - } - - public Integer getUserTagMaxDocumentTagsAsInteger() { - return getAsInteger(FessConfig.USER_TAG_MAX_DOCUMENT_TAGS); - } - public String getFormAdminMaxInputSize() { return get(FessConfig.FORM_ADMIN_MAX_INPUT_SIZE); } @@ -15504,7 +15406,6 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.INDEX_FIELD_CREATED, "created"); defaultMap.put(FessConfig.INDEX_FIELD_TIMESTAMP, "timestamp"); defaultMap.put(FessConfig.INDEX_FIELD_LABEL, "label"); - defaultMap.put(FessConfig.INDEX_FIELD_TAG, "tag"); defaultMap.put(FessConfig.INDEX_FIELD_MIMETYPE, "mimetype"); defaultMap.put(FessConfig.INDEX_FIELD_parent_id, "parent_id"); defaultMap.put(FessConfig.INDEX_FIELD_important_content, "important_content"); @@ -15544,7 +15445,7 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.INDEX_USER_INDEX, "fess_user"); defaultMap.put(FessConfig.INDEX_LOG_INDEX, "fess_log"); defaultMap.put(FessConfig.INDEX_DICTIONARY_PREFIX, ""); - defaultMap.put(FessConfig.INDEX_ADMIN_ARRAY_FIELDS, "lang,role,label,anchor,virtual_host,tag"); + defaultMap.put(FessConfig.INDEX_ADMIN_ARRAY_FIELDS, "lang,role,label,anchor,virtual_host"); defaultMap.put(FessConfig.INDEX_ADMIN_DATE_FIELDS, "expires,created,timestamp,last_modified"); defaultMap.put(FessConfig.INDEX_ADMIN_INTEGER_FIELDS, ""); defaultMap.put(FessConfig.INDEX_ADMIN_LONG_FIELDS, "content_length,favorite_count,click_count"); @@ -15684,9 +15585,6 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.LOGGING_CHAT_MAX_QUEUE_SIZE, "10000"); defaultMap.put(FessConfig.SEARCH_HISTORY_ENABLED, "true"); defaultMap.put(FessConfig.SEARCH_HISTORY_SIZE, "10"); - defaultMap.put(FessConfig.USER_TAG_ENABLED, "false"); - defaultMap.put(FessConfig.USER_TAG_NAME_MAX_LENGTH, "50"); - defaultMap.put(FessConfig.USER_TAG_MAX_DOCUMENT_TAGS, "100"); defaultMap.put(FessConfig.FORM_ADMIN_MAX_INPUT_SIZE, "10000"); defaultMap.put(FessConfig.FORM_ADMIN_LABEL_IN_CONFIG_ENABLED, "false"); defaultMap.put(FessConfig.FORM_ADMIN_DEFAULT_TEMPLATE_NAME, "__TEMPLATE__"); diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsLabelTypeBhv.java b/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsLabelTypeBhv.java index 1df57a3270..93ee796019 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsLabelTypeBhv.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsLabelTypeBhv.java @@ -77,7 +77,6 @@ protected RESULT createEntity(Map sou result.setCreatedTime(DfTypeUtil.toLong(source.get("createdTime"))); result.setExcludedPaths(DfTypeUtil.toString(source.get("excludedPaths"))); result.setIncludedPaths(DfTypeUtil.toString(source.get("includedPaths"))); - result.setKind(DfTypeUtil.toString(source.get("kind"))); result.setName(DfTypeUtil.toString(source.get("name"))); result.setPermissions(toStringArray(source.get("permissions"))); result.setSortOrder(DfTypeUtil.toInteger(source.get("sortOrder"))); diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsLabelType.java b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsLabelType.java index 733e779159..0dd6edf709 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsLabelType.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsLabelType.java @@ -49,9 +49,6 @@ public class BsLabelType extends EsAbstractEntity { /** includedPaths */ protected String includedPaths; - /** kind */ - protected String kind; - /** name */ protected String name; @@ -106,9 +103,6 @@ public Map toSource() { if (includedPaths != null) { addFieldToSource(sourceMap, "includedPaths", includedPaths); } - if (kind != null) { - addFieldToSource(sourceMap, "kind", kind); - } if (name != null) { addFieldToSource(sourceMap, "name", name); } @@ -147,7 +141,6 @@ protected String doBuildColumnString(String dm) { sb.append(dm).append(createdTime); sb.append(dm).append(excludedPaths); sb.append(dm).append(includedPaths); - sb.append(dm).append(kind); sb.append(dm).append(name); sb.append(dm).append(permissions); sb.append(dm).append(sortOrder); @@ -205,16 +198,6 @@ public void setIncludedPaths(String value) { this.includedPaths = value; } - public String getKind() { - checkSpecifiedProperty("kind"); - return convertEmptyToNull(kind); - } - - public void setKind(String value) { - registerModifiedProperty("kind"); - this.kind = value; - } - public String getName() { checkSpecifiedProperty("name"); return convertEmptyToNull(name); diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/LabelTypeDbm.java b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/LabelTypeDbm.java index f7b1b40264..0d00937478 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/LabelTypeDbm.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/LabelTypeDbm.java @@ -87,7 +87,6 @@ public DBDef getCurrentDBDef() { "excludedPaths"); setupEpg(_epgMap, et -> ((LabelType) et).getIncludedPaths(), (et, vl) -> ((LabelType) et).setIncludedPaths(DfTypeUtil.toString(vl)), "includedPaths"); - setupEpg(_epgMap, et -> ((LabelType) et).getKind(), (et, vl) -> ((LabelType) et).setKind(DfTypeUtil.toString(vl)), "kind"); setupEpg(_epgMap, et -> ((LabelType) et).getName(), (et, vl) -> ((LabelType) et).setName(DfTypeUtil.toString(vl)), "name"); setupEpg(_epgMap, et -> ((LabelType) et).getPermissions(), (et, vl) -> ((LabelType) et).setPermissions((String[]) vl), "permissions"); @@ -144,8 +143,6 @@ public TableSqlName getTableSqlName() { false, false, false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); protected final ColumnInfo _columnIncludedPaths = cci("includedPaths", "includedPaths", null, null, String.class, "includedPaths", null, false, false, false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); - protected final ColumnInfo _columnKind = cci("kind", "kind", null, null, String.class, "kind", null, false, false, false, "keyword", 0, - 0, null, null, false, null, null, null, null, null, false); protected final ColumnInfo _columnName = cci("name", "name", null, null, String.class, "name", null, false, false, false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); protected final ColumnInfo _columnPermissions = cci("permissions", "permissions", null, null, String[].class, "permissions", null, @@ -177,10 +174,6 @@ public ColumnInfo columnIncludedPaths() { return _columnIncludedPaths; } - public ColumnInfo columnKind() { - return _columnKind; - } - public ColumnInfo columnName() { return _columnName; } @@ -215,7 +208,6 @@ protected List ccil() { ls.add(columnCreatedTime()); ls.add(columnExcludedPaths()); ls.add(columnIncludedPaths()); - ls.add(columnKind()); ls.add(columnName()); ls.add(columnPermissions()); ls.add(columnSortOrder()); diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsLabelTypeCB.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsLabelTypeCB.java index c5252c8b7b..f5587af2c2 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsLabelTypeCB.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsLabelTypeCB.java @@ -192,10 +192,6 @@ public void columnIncludedPaths() { doColumn("includedPaths"); } - public void columnKind() { - doColumn("kind"); - } - public void columnName() { doColumn("name"); } diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsLabelTypeCA.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsLabelTypeCA.java index 9b4bee2956..33c7a3b5d4 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsLabelTypeCA.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsLabelTypeCA.java @@ -726,136 +726,6 @@ public void setIncludedPaths_Missing(String name, ConditionOptionCall opLambda) { - setKind_Terms("kind", opLambda, null); - } - - public void setKind_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { - setKind_Terms("kind", opLambda, aggsLambda); - } - - public void setKind_Terms(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { - TermsAggregationBuilder builder = regTermsA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - if (aggsLambda != null) { - LabelTypeCA ca = new LabelTypeCA(); - aggsLambda.callback(ca); - ca.getAggregationBuilderList().forEach(builder::subAggregation); - } - } - - public void setKind_SignificantTerms() { - setKind_SignificantTerms(null); - } - - public void setKind_SignificantTerms(ConditionOptionCall opLambda) { - setKind_SignificantTerms("kind", opLambda, null); - } - - public void setKind_SignificantTerms(ConditionOptionCall opLambda, - OperatorCall aggsLambda) { - setKind_SignificantTerms("kind", opLambda, aggsLambda); - } - - public void setKind_SignificantTerms(String name, ConditionOptionCall opLambda, - OperatorCall aggsLambda) { - SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - if (aggsLambda != null) { - LabelTypeCA ca = new LabelTypeCA(); - aggsLambda.callback(ca); - ca.getAggregationBuilderList().forEach(builder::subAggregation); - } - } - - public void setKind_IpRange() { - setKind_IpRange(null); - } - - public void setKind_IpRange(ConditionOptionCall opLambda) { - setKind_IpRange("kind", opLambda, null); - } - - public void setKind_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { - setKind_IpRange("kind", opLambda, aggsLambda); - } - - public void setKind_IpRange(String name, ConditionOptionCall opLambda, - OperatorCall aggsLambda) { - IpRangeAggregationBuilder builder = regIpRangeA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - if (aggsLambda != null) { - LabelTypeCA ca = new LabelTypeCA(); - aggsLambda.callback(ca); - ca.getAggregationBuilderList().forEach(builder::subAggregation); - } - } - - public void setKind_Count() { - setKind_Count(null); - } - - public void setKind_Count(ConditionOptionCall opLambda) { - setKind_Count("kind", opLambda); - } - - public void setKind_Count(String name, ConditionOptionCall opLambda) { - ValueCountAggregationBuilder builder = regCountA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Cardinality() { - setKind_Cardinality(null); - } - - public void setKind_Cardinality(ConditionOptionCall opLambda) { - setKind_Cardinality("kind", opLambda); - } - - public void setKind_Cardinality(String name, ConditionOptionCall opLambda) { - CardinalityAggregationBuilder builder = regCardinalityA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Missing() { - setKind_Missing(null); - } - - public void setKind_Missing(ConditionOptionCall opLambda) { - setKind_Missing("kind", opLambda, null); - } - - public void setKind_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { - setKind_Missing("kind", opLambda, aggsLambda); - } - - public void setKind_Missing(String name, ConditionOptionCall opLambda, - OperatorCall aggsLambda) { - MissingAggregationBuilder builder = regMissingA(name, "kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - if (aggsLambda != null) { - LabelTypeCA ca = new LabelTypeCA(); - aggsLambda.callback(ca); - ca.getAggregationBuilderList().forEach(builder::subAggregation); - } - } - public void setName_Terms() { setName_Terms(null); } diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsLabelTypeCQ.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsLabelTypeCQ.java index 8c938fecb1..c9a13143d0 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsLabelTypeCQ.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsLabelTypeCQ.java @@ -1038,230 +1038,6 @@ public BsLabelTypeCQ addOrderBy_IncludedPaths_Desc() { return this; } - public void setKind_Equal(String kind) { - setKind_Term(kind, null); - } - - public void setKind_Equal(String kind, ConditionOptionCall opLambda) { - setKind_Term(kind, opLambda); - } - - public void setKind_Term(String kind) { - setKind_Term(kind, null); - } - - public void setKind_Term(String kind, ConditionOptionCall opLambda) { - TermQueryBuilder builder = regTermQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_NotEqual(String kind) { - setKind_NotTerm(kind, null); - } - - public void setKind_NotTerm(String kind) { - setKind_NotTerm(kind, null); - } - - public void setKind_NotEqual(String kind, ConditionOptionCall opLambda) { - setKind_NotTerm(kind, opLambda); - } - - public void setKind_NotTerm(String kind, ConditionOptionCall opLambda) { - not(not -> not.setKind_Term(kind), opLambda); - } - - public void setKind_Terms(Collection kindList) { - setKind_Terms(kindList, null); - } - - public void setKind_Terms(Collection kindList, ConditionOptionCall opLambda) { - TermsQueryBuilder builder = regTermsQ("kind", kindList); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_InScope(Collection kindList) { - setKind_Terms(kindList, null); - } - - public void setKind_InScope(Collection kindList, ConditionOptionCall opLambda) { - setKind_Terms(kindList, opLambda); - } - - public void setKind_Match(String kind) { - setKind_Match(kind, null); - } - - public void setKind_Match(String kind, ConditionOptionCall opLambda) { - MatchQueryBuilder builder = regMatchQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_MatchPhrase(String kind) { - setKind_MatchPhrase(kind, null); - } - - public void setKind_MatchPhrase(String kind, ConditionOptionCall opLambda) { - MatchPhraseQueryBuilder builder = regMatchPhraseQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_MatchPhrasePrefix(String kind) { - setKind_MatchPhrasePrefix(kind, null); - } - - public void setKind_MatchPhrasePrefix(String kind, ConditionOptionCall opLambda) { - MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Fuzzy(String kind) { - setKind_Fuzzy(kind, null); - } - - public void setKind_Fuzzy(String kind, ConditionOptionCall opLambda) { - MatchQueryBuilder builder = regFuzzyQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Prefix(String kind) { - setKind_Prefix(kind, null); - } - - public void setKind_Prefix(String kind, ConditionOptionCall opLambda) { - PrefixQueryBuilder builder = regPrefixQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Wildcard(String kind) { - setKind_Wildcard(kind, null); - } - - public void setKind_Wildcard(String kind, ConditionOptionCall opLambda) { - WildcardQueryBuilder builder = regWildcardQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Regexp(String kind) { - setKind_Regexp(kind, null); - } - - public void setKind_Regexp(String kind, ConditionOptionCall opLambda) { - RegexpQueryBuilder builder = regRegexpQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_SpanTerm(String kind) { - setKind_SpanTerm("kind", null); - } - - public void setKind_SpanTerm(String kind, ConditionOptionCall opLambda) { - SpanTermQueryBuilder builder = regSpanTermQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_GreaterThan(String kind) { - setKind_GreaterThan(kind, null); - } - - public void setKind_GreaterThan(String kind, ConditionOptionCall opLambda) { - final Object _value = kind; - RangeQueryBuilder builder = regRangeQ("kind", ConditionKey.CK_GREATER_THAN, _value); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_LessThan(String kind) { - setKind_LessThan(kind, null); - } - - public void setKind_LessThan(String kind, ConditionOptionCall opLambda) { - final Object _value = kind; - RangeQueryBuilder builder = regRangeQ("kind", ConditionKey.CK_LESS_THAN, _value); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_GreaterEqual(String kind) { - setKind_GreaterEqual(kind, null); - } - - public void setKind_GreaterEqual(String kind, ConditionOptionCall opLambda) { - final Object _value = kind; - RangeQueryBuilder builder = regRangeQ("kind", ConditionKey.CK_GREATER_EQUAL, _value); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_LessEqual(String kind) { - setKind_LessEqual(kind, null); - } - - public void setKind_LessEqual(String kind, ConditionOptionCall opLambda) { - final Object _value = kind; - RangeQueryBuilder builder = regRangeQ("kind", ConditionKey.CK_LESS_EQUAL, _value); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public void setKind_Exists() { - setKind_Exists(null); - } - - public void setKind_Exists(ConditionOptionCall opLambda) { - ExistsQueryBuilder builder = regExistsQ("kind"); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - @Deprecated - public void setKind_CommonTerms(String kind) { - setKind_CommonTerms(kind, null); - } - - @Deprecated - public void setKind_CommonTerms(String kind, ConditionOptionCall opLambda) { - CommonTermsQueryBuilder builder = regCommonTermsQ("kind", kind); - if (opLambda != null) { - opLambda.callback(builder); - } - } - - public BsLabelTypeCQ addOrderBy_Kind_Asc() { - regOBA("kind"); - return this; - } - - public BsLabelTypeCQ addOrderBy_Kind_Desc() { - regOBD("kind"); - return this; - } - public void setName_Equal(String name) { setName_Term(name, null); } diff --git a/src/main/java/org/codelibs/fess/opensearch/config/exentity/LabelType.java b/src/main/java/org/codelibs/fess/opensearch/config/exentity/LabelType.java index 525f3b976c..a66faec571 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/exentity/LabelType.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/exentity/LabelType.java @@ -26,16 +26,6 @@ public class LabelType extends BsLabelType { private static final long serialVersionUID = 1L; - - /** The kind of a label that the crawler assigns to documents by the included and excluded paths. */ - public static final String KIND_LABEL = "label"; - - /** - * The kind of a label that is a tag users add from the search screen: its name is the tag name, its included paths - * list the tagged URLs (matched exactly) and its permissions list who can see it, including the users who added it. - */ - public static final String KIND_TAG = "tag"; - private Locale locale; public String getId() { @@ -54,15 +44,6 @@ public void setVersionNo(final Long version) { asDocMeta().version(version); } - /** - * Returns whether this label type defines user tags. A label type without a kind is a plain label. - * - * @return true if the kind is {@value #KIND_TAG} - */ - public boolean isTagKind() { - return KIND_TAG.equals(getKind()); - } - public Locale getLocale() { if (locale == null) { if (getValue() == null) { @@ -76,7 +57,7 @@ public Locale getLocale() { @Override public String toString() { return "LabelType [createdBy=" + createdBy + ", createdTime=" + createdTime + ", excludedPaths=" + excludedPaths - + ", includedPaths=" + includedPaths + ", kind=" + kind + ", name=" + name + ", sortOrder=" + sortOrder + ", updatedBy=" - + updatedBy + ", updatedTime=" + updatedTime + ", value=" + value + ", docMeta=" + docMeta + "]"; + + ", includedPaths=" + includedPaths + ", name=" + name + ", sortOrder=" + sortOrder + ", updatedBy=" + updatedBy + + ", updatedTime=" + updatedTime + ", value=" + value + ", docMeta=" + docMeta + "]"; } } diff --git a/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java b/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java index 1f217737a4..ad2c9d399f 100644 --- a/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java @@ -85,11 +85,6 @@ protected QueryBuilder convertFuzzyQuery(final QueryContext context, final Fuzzy final Term term = fuzzyQuery.getTerm(); final String field = getSearchField(context.getDefaultField(), term.field()); - if (isRestrictedTagField(context, field)) { - // a tag value is a hash of the name: a fuzzy match has no use but to probe for tags the caller cannot see - return buildHiddenTagQuery(context, field, term.text()); - } - if (Constants.DEFAULT_FIELD.equals(field)) { final String text = term.text(); context.addFieldLog(field, text); diff --git a/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java b/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java index 7afd4639ec..3388c084d8 100644 --- a/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java @@ -30,7 +30,6 @@ import org.lastaflute.core.message.UserMessages; import org.codelibs.fesen.opensearch.index.query.MatchPhraseQueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilder; -import org.codelibs.fesen.opensearch.index.query.QueryBuilders; /** * Query command for phrase queries. @@ -99,14 +98,6 @@ protected QueryBuilder convertPhraseQuery(final FessConfig fessConfig, final Que final String text = String.join(" ", texts); final int slop = phraseQuery.getSlop(); - if (isRestrictedTagField(context, field)) { - if (texts.length != 1 || !isVisibleTag(context, texts[0])) { - return buildHiddenTagQuery(context, field, text); - } - context.addFieldLog(field, text); - return QueryBuilders.termQuery(field, texts[0]).boost(boost); - } - if (Constants.DEFAULT_FIELD.equals(field)) { context.addFieldLog(field, text); stream(texts).of(stream -> stream.forEach(t -> context.addHighlightedQuery(t))); diff --git a/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java b/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java index 5175d67b82..5702951aca 100644 --- a/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java @@ -76,11 +76,6 @@ protected QueryBuilder convertPrefixQuery(final QueryContext context, final Pref final String field = getSearchField(context.getDefaultField(), prefixQuery.getField()); final String text = prefixQuery.getPrefix().text(); - if (isRestrictedTagField(context, field)) { - // a tag value is a hash of the name: a prefix has no use but to probe for tags the caller cannot see - return buildHiddenTagQuery(context, field, text + "*"); - } - if (Constants.DEFAULT_FIELD.equals(field)) { context.addFieldLog(field, text + "*"); context.addHighlightedQuery(text); diff --git a/src/main/java/org/codelibs/fess/query/QueryCommand.java b/src/main/java/org/codelibs/fess/query/QueryCommand.java index 34b0264040..beca57fd7e 100644 --- a/src/main/java/org/codelibs/fess/query/QueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/QueryCommand.java @@ -18,24 +18,17 @@ import static org.codelibs.core.stream.StreamUtil.stream; import java.lang.Character.UnicodeBlock; -import java.util.Collections; -import java.util.Locale; -import java.util.Set; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import org.apache.lucene.search.Query; import org.codelibs.core.lang.StringUtil; import org.codelibs.fess.Constants; import org.codelibs.fess.entity.QueryContext; -import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; import org.codelibs.fess.mylasta.direction.FessConfig; import org.codelibs.fess.util.ComponentUtil; import org.dbflute.optional.OptionalThing; import org.lastaflute.web.util.LaRequestUtil; import org.codelibs.fesen.opensearch.index.query.BoolQueryBuilder; import org.codelibs.fesen.opensearch.index.query.DisMaxQueryBuilder; -import org.codelibs.fesen.opensearch.index.query.MatchNoneQueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilders; import org.codelibs.fesen.opensearch.search.sort.SortBuilder; @@ -47,7 +40,6 @@ * Provides common functionality for processing and executing search queries. */ public abstract class QueryCommand { - private static final Logger logger = LogManager.getLogger(QueryCommand.class); /** * Default constructor for QueryCommand. @@ -120,64 +112,6 @@ protected boolean isSearchField(final String field) { return config.searchFieldSet != null && config.searchFieldSet.contains(field); } - /** - * Checks if a condition on the field must be limited to the tags that the caller can see. The {@code tag} field - * holds the values of every tag of a document, including tags whose label permissions do not include the caller, - * so a condition on it is restricted for every search except the admin search. - * @param context The query context. - * @param field The field name. - * @return True if the field is the tag field and the condition must be restricted. - */ - protected boolean isRestrictedTagField(final QueryContext context, final String field) { - return field != null && field.equals(ComponentUtil.getFessConfig().getIndexFieldTag()) - && !SearchRequestType.ADMIN_SEARCH.equals(context.getSearchRequestType()); - } - - /** - * Checks if the caller can see a tag. The visible tags are resolved once per query context. - * @param context The query context. - * @param value The tag value. - * @return True if the tag is visible to the caller. - */ - protected boolean isVisibleTag(final QueryContext context, final String value) { - Set valueSet = context.getVisibleTagValueSet(); - if (valueSet == null) { - valueSet = getVisibleTagValueSet(context); - context.setVisibleTagValueSet(valueSet); - } - return valueSet.contains(value); - } - - /** - * Resolves the values of the tags that the caller can see. - * @param context The query context. - * @return The visible tag values, or an empty set if they cannot be resolved. - */ - protected Set getVisibleTagValueSet(final QueryContext context) { - try { - final SearchRequestType searchRequestType = - context.getSearchRequestType() != null ? context.getSearchRequestType() : SearchRequestType.JSON; - final Locale locale = LaRequestUtil.getOptionalRequest().map(request -> request.getLocale()).orElse(null); - return ComponentUtil.getLabelTypeHelper().getTagValueSet(searchRequestType, locale != null ? locale : Locale.ROOT); - } catch (final RuntimeException e) { - logger.debug("Failed to resolve the visible tags; no tag condition matches.", e); - return Collections.emptySet(); - } - } - - /** - * Builds the query for a condition on the tag field that the caller may not use: it matches no document, so the - * condition does not reveal which documents carry a tag that the caller cannot see. - * @param context The query context. - * @param field The field name. - * @param text The query text. - * @return A query that matches no document. - */ - protected QueryBuilder buildHiddenTagQuery(final QueryContext context, final String field, final String text) { - context.addFieldLog(field, text); - return new MatchNoneQueryBuilder(); - } - /** * Gets the query languages from the current request. * @return An optional containing the query languages array, or empty if not available. diff --git a/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java b/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java index a26e6f61b6..49a834a949 100644 --- a/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java +++ b/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java @@ -143,7 +143,6 @@ public void init() { fessConfig.getIndexFieldThumbnail(), // fessConfig.getIndexFieldClickCount(), // fessConfig.getIndexFieldFavoriteCount(), // - fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldConfigId(), // fessConfig.getIndexFieldLang(), // fessConfig.getIndexFieldHasCache()); @@ -225,7 +224,6 @@ public void init() { fessConfig.getIndexFieldAnchor(), // fessConfig.getIndexFieldClickCount(), // fessConfig.getIndexFieldFavoriteCount(), // - fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldLang()); // Initialize Set for O(1) lookup performance searchFieldSet = new HashSet<>(); @@ -245,7 +243,6 @@ public void init() { fessConfig.getIndexFieldOwner(), // fessConfig.getIndexFieldLastModifier(), // fessConfig.getIndexFieldLabel(), // - fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldSegment()); // Initialize Set for O(1) lookup performance facetFieldSet = new HashSet<>(); @@ -324,7 +321,6 @@ public void init() { fessConfig.getIndexFieldSegment(), // fessConfig.getIndexFieldSeqNo(), // fessConfig.getIndexFieldSite(), // - fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldTimestamp(), // fessConfig.getIndexFieldUrl(), // fessConfig.getIndexFieldVersion())); diff --git a/src/main/java/org/codelibs/fess/query/TermQueryCommand.java b/src/main/java/org/codelibs/fess/query/TermQueryCommand.java index a44a0bc00e..74f7fe2740 100644 --- a/src/main/java/org/codelibs/fess/query/TermQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/TermQueryCommand.java @@ -98,9 +98,6 @@ protected QueryBuilder convertTermQuery(final QueryContext context, final TermQu */ protected QueryBuilder convertTermQuery(final FessConfig fessConfig, final QueryContext context, final TermQuery termQuery, final float boost, final String field, final String text) { - if (isRestrictedTagField(context, field) && !isVisibleTag(context, text)) { - return buildHiddenTagQuery(context, field, text); - } if (fessConfig.getQueryReplaceTermWithPrefixQueryAsBoolean() && text.length() > 1 && text.endsWith("*")) { return convertPrefixQuery(fessConfig, context, termQuery, boost, field, text); } diff --git a/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java b/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java index 2459ae443b..2e9d102855 100644 --- a/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java @@ -74,11 +74,6 @@ protected QueryBuilder convertTermRangeQuery(final QueryContext context, final T final FessConfig fessConfig = ComponentUtil.getFessConfig(); final String field = getSearchField(context.getDefaultField(), termRangeQuery.getField()); - if (isRestrictedTagField(context, field)) { - // a tag value is a hash of the name: a range has no use but to probe for tags the caller cannot see - return buildHiddenTagQuery(context, field, termRangeQuery.toString(field)); - } - if (!isSearchField(field)) { final StringBuilder queryBuf = new StringBuilder(); queryBuf.append(termRangeQuery.includesLower() ? '[' : '{'); diff --git a/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java b/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java index 07a34030ab..aa0188d229 100644 --- a/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java @@ -80,10 +80,6 @@ public QueryBuilder execute(final QueryContext context, final Query query, final protected QueryBuilder convertWildcardQuery(final QueryContext context, final WildcardQuery wildcardQuery, final float boost) { final FessConfig fessConfig = ComponentUtil.getFessConfig(); final String field = getSearchField(context.getDefaultField(), wildcardQuery.getField()); - if (isRestrictedTagField(context, field)) { - // a tag value is a hash of the name: a pattern has no use but to probe for tags the caller cannot see - return buildHiddenTagQuery(context, field, wildcardQuery.getTerm().text()); - } if (Constants.DEFAULT_FIELD.equals(field)) { final String text = wildcardQuery.getTerm().text(); context.addFieldLog(field, text); diff --git a/src/main/java/org/codelibs/fess/util/ComponentUtil.java b/src/main/java/org/codelibs/fess/util/ComponentUtil.java index 47f8d6a511..85f4965ed0 100644 --- a/src/main/java/org/codelibs/fess/util/ComponentUtil.java +++ b/src/main/java/org/codelibs/fess/util/ComponentUtil.java @@ -86,7 +86,6 @@ import org.codelibs.fess.helper.SseResponseHelper; import org.codelibs.fess.helper.SuggestHelper; import org.codelibs.fess.helper.SystemHelper; -import org.codelibs.fess.helper.TagHelper; import org.codelibs.fess.helper.ThemeArtifactHelper; import org.codelibs.fess.helper.UserAgentHelper; import org.codelibs.fess.helper.UserInfoHelper; @@ -203,8 +202,6 @@ public final class ComponentUtil { private static final String SUGGEST_HELPER = "suggestHelper"; - private static final String TAG_HELPER = "tagHelper"; - private static final String SEARCH_ENGINE_CLIENT = "searchEngineClient"; private static final String DICTIONARY_MANAGER = "dictionaryManager"; @@ -642,14 +639,6 @@ public static SuggestHelper getSuggestHelper() { return getComponent(SUGGEST_HELPER); } - /** - * Gets the tag helper component. - * @return The tag helper. - */ - public static TagHelper getTagHelper() { - return getComponent(TAG_HELPER); - } - /** * Gets the role query helper component. * @return The role query helper. diff --git a/src/main/resources/app.xml b/src/main/resources/app.xml index e1dfd107bf..cda5be8084 100644 --- a/src/main/resources/app.xml +++ b/src/main/resources/app.xml @@ -63,8 +63,6 @@ - - diff --git a/src/main/resources/fess_api.xml b/src/main/resources/fess_api.xml index 974466cb4a..0a607dfbd5 100644 --- a/src/main/resources/fess_api.xml +++ b/src/main/resources/fess_api.xml @@ -25,7 +25,6 @@ - diff --git a/src/main/resources/fess_config.properties b/src/main/resources/fess_config.properties index 46bcaf5494..099c43af6c 100644 --- a/src/main/resources/fess_config.properties +++ b/src/main/resources/fess_config.properties @@ -585,8 +585,6 @@ index.field.created=created index.field.timestamp=timestamp # Field name for label in the index. index.field.label=label -# Field name for the tags of the document in the index: the values of the labels of the kind "tag" whose included paths list the document URL. -index.field.tag=tag # Field name for MIME type in the index. index.field.mimetype=mimetype # Field name for parent ID in the index. @@ -674,7 +672,7 @@ index.dictionary.prefix= #> Document Management # Array-type fields for admin in the index. -index.admin.array.fields=lang,role,label,anchor,virtual_host,tag +index.admin.array.fields=lang,role,label,anchor,virtual_host # Date-type fields for admin in the index. index.admin.date.fields=expires,created,timestamp,last_modified # Integer-type fields for admin in the index. @@ -1131,12 +1129,6 @@ logging.chat.max.queue.size=10000 search.history.enabled=true # Maximum number of search history entries returned per user. search.history.size=10 -# Whether logged-in users can tag documents from the search screen. A tag is a label of the kind "tag": its name is the tag name, its included paths list the tagged URLs and its permissions list the users who added it. -user.tag.enabled=false -# Maximum length of a tag name. -user.tag.name.max.length=50 -# Maximum number of tags on one document. -user.tag.max.document.tags=100 # ======================================================================================== # Web diff --git a/src/main/resources/fess_indices/_aws/fess/doc.json b/src/main/resources/fess_indices/_aws/fess/doc.json index 7143f68c0b..485f0749f8 100644 --- a/src/main/resources/fess_indices/_aws/fess/doc.json +++ b/src/main/resources/fess_indices/_aws/fess/doc.json @@ -596,9 +596,6 @@ "label": { "type": "keyword" }, - "tag": { - "type": "keyword" - }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/_cloud/fess/doc.json b/src/main/resources/fess_indices/_cloud/fess/doc.json index 7143f68c0b..485f0749f8 100644 --- a/src/main/resources/fess_indices/_cloud/fess/doc.json +++ b/src/main/resources/fess_indices/_cloud/fess/doc.json @@ -596,9 +596,6 @@ "label": { "type": "keyword" }, - "tag": { - "type": "keyword" - }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/fess/doc.json b/src/main/resources/fess_indices/fess/doc.json index ba1fdfa45f..cd3b8ef50d 100644 --- a/src/main/resources/fess_indices/fess/doc.json +++ b/src/main/resources/fess_indices/fess/doc.json @@ -598,9 +598,6 @@ "label": { "type": "keyword" }, - "tag": { - "type": "keyword" - }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/fess_config.label_type/label_type.json b/src/main/resources/fess_indices/fess_config.label_type/label_type.json index 56893510bb..ffcbef3818 100644 --- a/src/main/resources/fess_indices/fess_config.label_type/label_type.json +++ b/src/main/resources/fess_indices/fess_config.label_type/label_type.json @@ -6,9 +6,6 @@ "value": { "type": "keyword" }, - "kind": { - "type": "keyword" - }, "includedPaths": { "type": "keyword" }, diff --git a/src/main/resources/fess_label.properties b/src/main/resources/fess_label.properties index 8c7472b920..ee535ecfeb 100644 --- a/src/main/resources/fess_label.properties +++ b/src/main/resources/fess_label.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Label labels.labeltype_title_details=Label labels.labeltype_name=Name labels.labeltype_value=Value -labels.labeltype_kind=Kind -labels.labeltype_kind_label=Label -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=A Tag is a tag that users add from the search screen: Name is the tag name, Included Paths lists the tagged URLs (one URL per line, exact match), and Permissions decides who can see it. A user who adds a tag is added to its Permissions. labels.labeltype_included_paths=Included Paths labels.labeltype_excluded_paths=Excluded Paths labels.roletype_configuration=Role diff --git a/src/main/resources/fess_label_de.properties b/src/main/resources/fess_label_de.properties index 46968eb9fe..d13ce835c1 100644 --- a/src/main/resources/fess_label_de.properties +++ b/src/main/resources/fess_label_de.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Label labels.labeltype_title_details=Label labels.labeltype_name=Name labels.labeltype_value=Wert -labels.labeltype_kind=Art -labels.labeltype_kind_label=Label -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Ein Tag ist ein Tag, den Benutzer auf der Suchseite hinzufügen: Name ist der Tag-Name, Eingeschlossene Pfade listet die getaggten URLs auf (eine URL pro Zeile, exakte Übereinstimmung), und Berechtigungen legt fest, wer ihn sehen kann. Ein Benutzer, der einen Tag hinzufügt, wird zu dessen Berechtigungen hinzugefügt. labels.labeltype_included_paths=Eingeschlossene Pfade labels.labeltype_excluded_paths=Ausgeschlossene Pfade labels.roletype_configuration=Rolle diff --git a/src/main/resources/fess_label_en.properties b/src/main/resources/fess_label_en.properties index c2b23f8e7a..0313f4741a 100644 --- a/src/main/resources/fess_label_en.properties +++ b/src/main/resources/fess_label_en.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Label labels.labeltype_title_details=Label labels.labeltype_name=Name labels.labeltype_value=Value -labels.labeltype_kind=Kind -labels.labeltype_kind_label=Label -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=A Tag is a tag that users add from the search screen: Name is the tag name, Included Paths lists the tagged URLs (one URL per line, exact match), and Permissions decides who can see it. A user who adds a tag is added to its Permissions. labels.labeltype_included_paths=Included Paths labels.labeltype_excluded_paths=Excluded Paths labels.roletype_configuration=Role diff --git a/src/main/resources/fess_label_es.properties b/src/main/resources/fess_label_es.properties index 35108e371f..e384f19298 100644 --- a/src/main/resources/fess_label_es.properties +++ b/src/main/resources/fess_label_es.properties @@ -512,10 +512,6 @@ labels.labeltype_configuration=Etiqueta labels.labeltype_title_details=Etiqueta labels.labeltype_name=Nombre labels.labeltype_value=Valor -labels.labeltype_kind=Tipo -labels.labeltype_kind_label=Etiqueta -labels.labeltype_kind_tag=Etiqueta de usuario -labels.labeltype_kind_help=Una Etiqueta de usuario es una etiqueta que los usuarios añaden desde la pantalla de búsqueda: Nombre es el nombre de la etiqueta, Rutas incluidas enumera las URL etiquetadas (una URL por línea, coincidencia exacta) y Permisos determina quién puede verla. El usuario que añade una etiqueta se agrega a sus Permisos. labels.labeltype_included_paths=Rutas incluidas labels.labeltype_excluded_paths=Rutas excluidas labels.roletype_configuration=Rol diff --git a/src/main/resources/fess_label_fr.properties b/src/main/resources/fess_label_fr.properties index 78b9548248..10fdfffeef 100644 --- a/src/main/resources/fess_label_fr.properties +++ b/src/main/resources/fess_label_fr.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Étiquette labels.labeltype_title_details=Étiquette labels.labeltype_name=Nom labels.labeltype_value=Valeur -labels.labeltype_kind=Type -labels.labeltype_kind_label=Étiquette -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Un Tag est un tag que les utilisateurs ajoutent depuis l'écran de recherche : Nom est le nom du tag, Chemins inclus liste les URL taguées (une URL par ligne, correspondance exacte) et Autorisations détermine qui peut le voir. Un utilisateur qui ajoute un tag est ajouté à ses Autorisations. labels.labeltype_included_paths=Chemins inclus labels.labeltype_excluded_paths=Chemins exclus labels.roletype_configuration=Rôle diff --git a/src/main/resources/fess_label_hi.properties b/src/main/resources/fess_label_hi.properties index 2bed7e97b1..0df079fa06 100644 --- a/src/main/resources/fess_label_hi.properties +++ b/src/main/resources/fess_label_hi.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=लेबल labels.labeltype_title_details=लेबल labels.labeltype_name=नाम labels.labeltype_value=मान -labels.labeltype_kind=प्रकार -labels.labeltype_kind_label=लेबल -labels.labeltype_kind_tag=टैग -labels.labeltype_kind_help=टैग वह टैग है जिसे उपयोगकर्ता खोज स्क्रीन से जोड़ते हैं: नाम टैग का नाम है, शामिल पथ में टैग किए गए URL सूचीबद्ध होते हैं (प्रति पंक्ति एक URL, सटीक मिलान), और अनुमतियां तय करती हैं कि इसे कौन देख सकता है। टैग जोड़ने वाले उपयोगकर्ता को उसकी अनुमतियां में जोड़ दिया जाता है। labels.labeltype_included_paths=शामिल पथ labels.labeltype_excluded_paths=बहिष्कृत पथ labels.roletype_configuration=भूमिका diff --git a/src/main/resources/fess_label_id.properties b/src/main/resources/fess_label_id.properties index 9e69ce6baa..d05dfb2ab6 100644 --- a/src/main/resources/fess_label_id.properties +++ b/src/main/resources/fess_label_id.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Label labels.labeltype_title_details=Label labels.labeltype_name=Nama labels.labeltype_value=Nilai -labels.labeltype_kind=Jenis -labels.labeltype_kind_label=Label -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Tag adalah tag yang ditambahkan pengguna dari layar pencarian: Nama adalah nama tag, Jalur yang Disertakan berisi daftar URL yang diberi tag (satu URL per baris, kecocokan persis), dan Izin menentukan siapa yang dapat melihatnya. Pengguna yang menambahkan tag akan ditambahkan ke Izin tag tersebut. labels.labeltype_included_paths=Jalur yang Disertakan labels.labeltype_excluded_paths=Jalur yang Dikecualikan labels.roletype_configuration=Peran diff --git a/src/main/resources/fess_label_it.properties b/src/main/resources/fess_label_it.properties index 7c515e0f7d..9e7b4e3de2 100644 --- a/src/main/resources/fess_label_it.properties +++ b/src/main/resources/fess_label_it.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Etichetta labels.labeltype_title_details=Etichetta labels.labeltype_name=Nome labels.labeltype_value=Valore -labels.labeltype_kind=Tipo -labels.labeltype_kind_label=Etichetta -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Un Tag è un tag che gli utenti aggiungono dalla schermata di ricerca: Nome è il nome del tag, Percorsi inclusi elenca gli URL taggati (un URL per riga, corrispondenza esatta) e Permessi stabilisce chi può vederlo. L'utente che aggiunge un tag viene aggiunto ai suoi Permessi. labels.labeltype_included_paths=Percorsi inclusi labels.labeltype_excluded_paths=Percorsi esclusi labels.roletype_configuration=Ruolo diff --git a/src/main/resources/fess_label_ja.properties b/src/main/resources/fess_label_ja.properties index b022853589..43e9fa17cd 100644 --- a/src/main/resources/fess_label_ja.properties +++ b/src/main/resources/fess_label_ja.properties @@ -510,10 +510,6 @@ labels.labeltype_configuration=ラベル labels.labeltype_title_details=ラベル labels.labeltype_name=名前 labels.labeltype_value=値 -labels.labeltype_kind=種類 -labels.labeltype_kind_label=ラベル -labels.labeltype_kind_tag=タグ -labels.labeltype_kind_help=タグは、ユーザーが検索画面から追加するタグです。名前はタグ名、対象とするパスはタグ付けされたURL (1行に1つのURL、完全一致)、パーミッションはタグを表示できるユーザーを指定します。タグを追加したユーザーは、そのタグのパーミッションに追加されます。 labels.labeltype_included_paths=対象とするパス labels.labeltype_excluded_paths=除外するパス labels.roletype_configuration=ロール diff --git a/src/main/resources/fess_label_ko.properties b/src/main/resources/fess_label_ko.properties index a73db3a970..341cd4cd5d 100644 --- a/src/main/resources/fess_label_ko.properties +++ b/src/main/resources/fess_label_ko.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=라벨 labels.labeltype_title_details=라벨 labels.labeltype_name=이름 labels.labeltype_value=값 -labels.labeltype_kind=종류 -labels.labeltype_kind_label=라벨 -labels.labeltype_kind_tag=태그 -labels.labeltype_kind_help=태그는 사용자가 검색 화면에서 추가하는 태그입니다. 이름은 태그 이름이고, 대상 경로는 태그가 지정된 URL 목록(한 줄에 하나의 URL, 정확히 일치)이며, 권한은 태그를 볼 수 있는 사용자를 결정합니다. 태그를 추가한 사용자는 해당 태그의 권한에 추가됩니다. labels.labeltype_included_paths=대상 경로 labels.labeltype_excluded_paths=제외 경로 labels.roletype_configuration=역할 diff --git a/src/main/resources/fess_label_nl.properties b/src/main/resources/fess_label_nl.properties index 5b1659fe18..b2ec37182e 100644 --- a/src/main/resources/fess_label_nl.properties +++ b/src/main/resources/fess_label_nl.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Label labels.labeltype_title_details=Label labels.labeltype_name=Naam labels.labeltype_value=Waarde -labels.labeltype_kind=Soort -labels.labeltype_kind_label=Label -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Een Tag is een tag die gebruikers vanuit het zoekscherm toevoegen: Naam is de tagnaam, Inbegrepen paden bevat de getagde URL's (één URL per regel, exacte overeenkomst) en Machtigingen bepaalt wie de tag kan zien. Een gebruiker die een tag toevoegt, wordt aan de Machtigingen ervan toegevoegd. labels.labeltype_included_paths=Inbegrepen paden labels.labeltype_excluded_paths=Uitgesloten paden labels.roletype_configuration=Rol diff --git a/src/main/resources/fess_label_pl.properties b/src/main/resources/fess_label_pl.properties index 2c6e405185..257be9e7eb 100644 --- a/src/main/resources/fess_label_pl.properties +++ b/src/main/resources/fess_label_pl.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Etykieta labels.labeltype_title_details=Etykieta labels.labeltype_name=Nazwa labels.labeltype_value=Wartość -labels.labeltype_kind=Rodzaj -labels.labeltype_kind_label=Etykieta -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Tag to tag dodawany przez użytkowników z ekranu wyszukiwania: Nazwa to nazwa tagu, Uwzględnione ścieżki zawierają oznaczone adresy URL (jeden URL w wierszu, dokładne dopasowanie), a Uprawnienia określają, kto może go zobaczyć. Użytkownik, który doda tag, zostaje dodany do jego Uprawnień. labels.labeltype_included_paths=Uwzględnione ścieżki labels.labeltype_excluded_paths=Wykluczone ścieżki labels.roletype_configuration=Rola diff --git a/src/main/resources/fess_label_pt_BR.properties b/src/main/resources/fess_label_pt_BR.properties index 1e15f4d975..9936b31d7a 100644 --- a/src/main/resources/fess_label_pt_BR.properties +++ b/src/main/resources/fess_label_pt_BR.properties @@ -512,10 +512,6 @@ labels.labeltype_configuration=Rótulo labels.labeltype_title_details=Rótulo labels.labeltype_name=Nome labels.labeltype_value=Valor -labels.labeltype_kind=Tipo -labels.labeltype_kind_label=Rótulo -labels.labeltype_kind_tag=Tag -labels.labeltype_kind_help=Uma Tag é uma tag que os usuários adicionam pela tela de pesquisa: Nome é o nome da tag, Caminhos incluídos lista as URLs marcadas (uma URL por linha, correspondência exata) e Permissões define quem pode vê-la. O usuário que adiciona uma tag é incluído em suas Permissões. labels.labeltype_included_paths=Caminhos incluídos labels.labeltype_excluded_paths=Caminhos excluídos labels.roletype_configuration=Função diff --git a/src/main/resources/fess_label_ru.properties b/src/main/resources/fess_label_ru.properties index 61a67dd998..1b7e72af1f 100644 --- a/src/main/resources/fess_label_ru.properties +++ b/src/main/resources/fess_label_ru.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Метка labels.labeltype_title_details=Метка labels.labeltype_name=Имя labels.labeltype_value=Значение -labels.labeltype_kind=Тип -labels.labeltype_kind_label=Метка -labels.labeltype_kind_tag=Тег -labels.labeltype_kind_help=Тег — это тег, который пользователи добавляют на странице поиска: «Имя» — название тега, «Включенные пути» — список помеченных URL (по одному URL в строке, точное совпадение), а «Разрешения» определяют, кто может его видеть. Пользователь, добавивший тег, добавляется в его «Разрешения». labels.labeltype_included_paths=Включенные пути labels.labeltype_excluded_paths=Исключенные пути labels.roletype_configuration=Роль diff --git a/src/main/resources/fess_label_tr.properties b/src/main/resources/fess_label_tr.properties index 63ce1d8642..2c520aa1b6 100644 --- a/src/main/resources/fess_label_tr.properties +++ b/src/main/resources/fess_label_tr.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=Etiket labels.labeltype_title_details=Etiket labels.labeltype_name=Ad labels.labeltype_value=Değer -labels.labeltype_kind=Tür -labels.labeltype_kind_label=Etiket -labels.labeltype_kind_tag=Kullanıcı etiketi -labels.labeltype_kind_help=Kullanıcı etiketi, kullanıcıların arama ekranından eklediği bir etikettir: Ad etiketin adıdır, Dahil Edilen Yollar etiketlenen URL'leri listeler (her satıra bir URL, tam eşleşme) ve İzinler onu kimin görebileceğini belirler. Etiket ekleyen kullanıcı, o etiketin İzinler alanına eklenir. labels.labeltype_included_paths=Dahil Edilen Yollar labels.labeltype_excluded_paths=Hariç Tutulan Yollar labels.roletype_configuration=Rol diff --git a/src/main/resources/fess_label_zh_CN.properties b/src/main/resources/fess_label_zh_CN.properties index f6accea4e4..3ddebe355a 100644 --- a/src/main/resources/fess_label_zh_CN.properties +++ b/src/main/resources/fess_label_zh_CN.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=标签 labels.labeltype_title_details=标签 labels.labeltype_name=名称 labels.labeltype_value=值 -labels.labeltype_kind=类型 -labels.labeltype_kind_label=标签 -labels.labeltype_kind_tag=用户标签 -labels.labeltype_kind_help=用户标签是用户在搜索页面添加的标签:名称为标签名,包含的路径列出被添加标签的URL(每行一个URL,完全匹配),权限决定谁可以查看该标签。添加标签的用户会被加入该标签的权限中。 labels.labeltype_included_paths=包含的路径 labels.labeltype_excluded_paths=排除的路径 labels.roletype_configuration=角色 diff --git a/src/main/resources/fess_label_zh_TW.properties b/src/main/resources/fess_label_zh_TW.properties index 5e1cbba1dd..7396a49941 100644 --- a/src/main/resources/fess_label_zh_TW.properties +++ b/src/main/resources/fess_label_zh_TW.properties @@ -513,10 +513,6 @@ labels.labeltype_configuration=標籤 labels.labeltype_title_details=標籤 labels.labeltype_name=名稱 labels.labeltype_value=值 -labels.labeltype_kind=類型 -labels.labeltype_kind_label=標籤 -labels.labeltype_kind_tag=使用者標籤 -labels.labeltype_kind_help=使用者標籤是使用者在搜尋畫面新增的標籤:名稱為標籤名稱,包含的路徑列出被加上標籤的URL(每行一個URL,完全符合),權限決定誰可以檢視該標籤。新增標籤的使用者會被加入該標籤的權限中。 labels.labeltype_included_paths=包含的路徑 labels.labeltype_excluded_paths=排除的路徑 labels.roletype_configuration=角色 diff --git a/src/main/webapp/WEB-INF/view/admin/labeltype/admin_labeltype_details.jsp b/src/main/webapp/WEB-INF/view/admin/labeltype/admin_labeltype_details.jsp index a921435447..27365e9a85 100644 --- a/src/main/webapp/WEB-INF/view/admin/labeltype/admin_labeltype_details.jsp +++ b/src/main/webapp/WEB-INF/view/admin/labeltype/admin_labeltype_details.jsp @@ -66,15 +66,6 @@ ${fe:html(true)} ${f:h(value)} - - - - - - - ${f:br(f:h(includedPaths))} -
- -
- - - - - - -
-
diff --git a/src/main/webapp/themes/bootstrap/README.md b/src/main/webapp/themes/bootstrap/README.md index 6bf0a54403..79a789f08d 100644 --- a/src/main/webapp/themes/bootstrap/README.md +++ b/src/main/webapp/themes/bootstrap/README.md @@ -51,7 +51,7 @@ of truth — third-party themes are encouraged to start by copying it. |---|---| | `api.js` | `GET /ui/config` | | `auth.js` | `GET /ui/config`, `GET /auth/me`, `POST /auth/login`, `POST /auth/logout`, `POST /auth/password` | -| `search.js` | `GET /search`, `GET /suggest-words`, `GET /search-history`, `GET /labels`, `GET /popular-words`, `GET /related-queries`, `GET /related-content`, `GET /favorites`, `POST /documents/{id}/favorite`, `GET /documents/{id}/tags`, `POST /documents/{id}/tags`, `DELETE /documents/{id}/tags` | +| `search.js` | `GET /search`, `GET /suggest-words`, `GET /search-history`, `GET /labels`, `GET /popular-words`, `GET /related-queries`, `GET /related-content`, `GET /favorites`, `POST /documents/{id}/favorite` | | `app.js` | `GET /popular-words` | | `cache.js` | `GET /cache/{id}` | | `profile.js` | `POST /auth/password` | diff --git a/src/main/webapp/themes/bootstrap/assets/api.js b/src/main/webapp/themes/bootstrap/assets/api.js index 61e5ac2a22..22e1d696b2 100644 --- a/src/main/webapp/themes/bootstrap/assets/api.js +++ b/src/main/webapp/themes/bootstrap/assets/api.js @@ -106,29 +106,6 @@ export async function post(path, body) { return unwrap(resp); } -/** - * DELETE /api/v2{path}, with the same CSRF header, credentials and envelope handling as post(). - * Named del because delete is a reserved word. - * @param {string} path - * @param {object} [params] - query parameters - */ -export async function del(path, params) { - let resp; - try { - resp = await fetch(BASE + path + qs(params), { - method: "DELETE", - credentials: "same-origin", - headers: { - "Accept": "application/json", - "X-Fess-CSRF-Token": csrfToken || "" - } - }); - } catch (e) { - throw new NetworkError(e); - } - return unwrap(resp); -} - /** * @deprecated — use sseStream for POST-based streaming (EventSource cannot send custom headers). * Kept for backward compatibility; will be removed once all callers migrate to sseStream. diff --git a/src/main/webapp/themes/bootstrap/assets/search.js b/src/main/webapp/themes/bootstrap/assets/search.js index bfa82ad544..1e7531240b 100644 --- a/src/main/webapp/themes/bootstrap/assets/search.js +++ b/src/main/webapp/themes/bootstrap/assets/search.js @@ -73,264 +73,6 @@ function el(tag, opts) { return node; } -// ─── Shared user tags (features.user_tag) ──────────────────────────────────── -// A tag is a name. Its value is an opaque id that fields.tag filters by and the tag API takes -// back; it is never parsed. Hits carry their tags as [{ value, name, mine }] (mine: the caller -// holds the tag and may remove it from their tags), the "tag" facet as [{ value, count, label }]. - -function userTagEnabled() { - return !!(api.getConfig()?.features?.user_tag); -} - -/** value -> name of the tags in the current response (hits, tag facet, editor answers), for the filter badges. */ -const tagNames = new Map(); - -function rememberTagName(value, name) { - if (value && name) tagNames.set(String(value), String(name)); -} - -/** Collect the tag names of a search response: the hits' tags and the tag facet's labels. */ -function collectTagNames(env) { - tagNames.clear(); - if (!userTagEnabled() || !env) return; - (Array.isArray(env.data) ? env.data : []).forEach(d => - (Array.isArray(d && d.tags) ? d.tags : []).forEach(tag => tag && rememberTagName(tag.value, tag.name))); - const field = (Array.isArray(env.facet_field) ? env.facet_field : []).find(f => f && f.name === "tag"); - ((field && field.result) || []).forEach(r => r && rememberTagName(r.value, r.label)); -} - -/** The text of a tag on a card and in the editor: its name, or its raw value without one. */ -function tagText(tag) { - return String(tag.name || tag.value || ""); -} - -/** The name of a tag value for the active-filter badge; the raw value when the response does not name it. */ -function tagNameOf(value) { - return tagNames.get(String(value)) || String(value); -} - -/** The /documents/{id}/tags path of a document. */ -function tagsPath(docId) { - return "/documents/" + encodeURIComponent(docId) + "/tags"; -} - -function isAuthError(e) { - return !!e && (e.code === "auth_required" || e.code === "AUTH_REQUIRED" || e.httpStatus === 401 || e.httpStatus === 403); -} - -function showLoginModal() { - if (!window.bootstrap || !bootstrap.Modal) { - console.warn("[fess] bootstrap not loaded; skipping modal show"); - } else { - bootstrap.Modal.getOrCreateInstance(document.getElementById("login-modal")).show(); - } -} - -/** Narrow the current search to one tag (fields.tag), keeping the query and the other filters. */ -function filterByTag(value) { - state.fields = { ...state.fields, tag: [value] }; - state.start = 0; - runSearch(); -} - -/** (Re-)render a card's tag chips; each one filters the search by its tag. */ -function renderTagChips(container, tags) { - while (container.firstChild) container.removeChild(container.firstChild); - (tags || []).forEach(tag => { - if (!tag || !tag.value) return; - rememberTagName(tag.value, tag.name); - const chip = el("button", { - className: "badge rounded-pill tag-chip me-1", - text: tagText(tag), - attrs: { type: "button" } - }); - chip.addEventListener("click", () => filterByTag(tag.value)); - container.appendChild(chip); - }); -} - -/** - * The inline tag editor of a result card: lists the document's tags (GET /documents/{id}/tags) - * with a remove button on the user's own ones (mine; removing takes the tag out of the user's - * tags, it may stay visible to others), and adds a tag by name with an input and an Add button. - * Each answer re-renders the list and hands the tags to onTags so the card's chips follow. - * Escape calls onClose. - * - * @param {string} docId - * @param {string} id - id of the editor element - * @param {{ onTags: function(Object[]), onClose: function() }} handlers - * @returns {{ node: HTMLElement, load: function(): Promise, focus: function() }} - */ -function buildTagEditor(docId, id, handlers) { - const node = el("div", { - className: "tag-editor border rounded p-2 mt-1", - attrs: { id, role: "group", "aria-label": t("tag.title") } - }); - const list = el("ul", { className: "list-unstyled d-flex flex-wrap gap-1 mb-2 tag-editor-list" }); - const empty = el("p", { className: "small text-body-secondary mb-2 tag-editor-empty d-none", text: t("tag.empty") }); - const form = el("form", { className: "d-flex flex-wrap align-items-center gap-1 tag-editor-form", attrs: { novalidate: "" } }); - form.appendChild(el("label", { className: "visually-hidden", text: t("tag.name"), attrs: { for: id + "-name" } })); - const input = el("input", { - className: "form-control form-control-sm w-auto", - attrs: { id: id + "-name", type: "text", maxlength: "50", autocomplete: "off", placeholder: t("tag.name") } - }); - form.appendChild(input); - const addBtn = el("button", { className: "btn btn-sm btn-primary", text: t("tag.add_button"), attrs: { type: "submit" } }); - form.appendChild(addBtn); - // A quiet inline message, like the other inline errors of this theme; polite, not an alert. - const error = el("div", { className: "small text-danger mt-1 tag-editor-error d-none", attrs: { "aria-live": "polite" } }); - node.appendChild(list); - node.appendChild(empty); - node.appendChild(form); - node.appendChild(error); - - let busy = false; - const setError = msg => { - error.textContent = msg || ""; - error.classList.toggle("d-none", !msg); - }; - // 401/403 asks for login like the favorite star; a 400 carries a message meant for the user. - const fail = e => { - if (isAuthError(e)) { showLoginModal(); return; } - const invalid = e && (e.code === "invalid_request" || e.code === "INVALID_REQUEST" || e.httpStatus === 400); - setError(invalid && e.message ? e.message : t("tag.error")); - }; - const focusForm = () => { - if (!form.classList.contains("d-none")) input.focus(); - else node.focus(); - }; - const remove = async (tag, btn) => { - if (busy) return; - busy = true; - btn.disabled = true; - setError(""); - try { - apply(await api.del(tagsPath(docId), { value: tag.value })); - // The button is gone with the re-rendered list; keep focus inside the editor. - focusForm(); - } catch (e) { - btn.disabled = false; - fail(e); - } finally { - busy = false; - } - }; - const renderList = tags => { - while (list.firstChild) list.removeChild(list.firstChild); - tags.forEach(tag => { - if (!tag || !tag.value) return; - const text = tagText(tag); - const li = el("li", { className: "badge rounded-pill tag-chip tag-editor-item d-inline-flex align-items-center gap-1" }); - li.appendChild(el("span", { text })); - if (tag.mine === true) { - const btn = el("button", { - className: "btn-close tag-remove", - attrs: { type: "button", "aria-label": t("tag.remove", { name: text }) } - }); - btn.addEventListener("click", () => remove(tag, btn)); - li.appendChild(btn); - } - list.appendChild(li); - }); - empty.classList.toggle("d-none", list.childElementCount > 0); - }; - const apply = env => { - const tags = Array.isArray(env && env.tags) ? env.tags : []; - renderList(tags); - form.classList.toggle("d-none", !(env && env.addable)); - handlers.onTags(tags); - }; - - form.addEventListener("submit", async ev => { - ev.preventDefault(); - const name = input.value.trim(); - if (!name || busy) return; - busy = true; - addBtn.disabled = true; - setError(""); - try { - apply(await api.post(tagsPath(docId), { name })); - input.value = ""; - input.focus(); - } catch (e) { - fail(e); - } finally { - busy = false; - addBtn.disabled = false; - } - }); - node.addEventListener("keydown", ev => { - if (ev.key !== "Escape") return; - ev.preventDefault(); - handlers.onClose(); - }); - // Focus target when the form is hidden (addable: false). - node.setAttribute("tabindex", "-1"); - - return { - node, - load: async () => { - setError(""); - try { - apply(await api.get(tagsPath(docId))); - } catch (e) { - fail(e); - } - }, - focus: () => input.focus() - }; -} - -/** - * The tag row of a result card: the document's tags as chips and, for a logged-in user, an - * "Add tag" toggle that opens the inline editor. Null when the feature is off or - * there is nothing to show. - * - * @param {Object} d - result document - * @param {number} idx0 - 0-based position of the card (for element ids) - * @returns {HTMLElement|null} - */ -function buildTagRow(d, idx0) { - if (!userTagEnabled() || !d.doc_id) return null; - const tags = Array.isArray(d.tags) ? d.tags : []; - const canAdd = api.isAuthenticated(); - if (tags.length === 0 && !canAdd) return null; - const row = el("div", { className: "tags" }); - const chips = el("span", { className: "tag-chips" }); - renderTagChips(chips, tags); - row.appendChild(chips); - if (!canAdd) return row; - - const editorId = "result" + idx0 + "-tag-editor"; - const toggle = el("button", { - className: "btn btn-link btn-sm p-0 tag-add-btn d-print-none", - attrs: { type: "button", "aria-expanded": "false" } - }); - toggle.appendChild(el("i", { className: "fa fa-tag me-1", attrs: { "aria-hidden": "true" } })); - toggle.appendChild(document.createTextNode(t("tag.add"))); - row.appendChild(toggle); - - let editor = null; - const close = () => { - if (editor) editor.node.classList.add("d-none"); - toggle.setAttribute("aria-expanded", "false"); - toggle.focus(); - }; - toggle.addEventListener("click", () => { - if (editor && !editor.node.classList.contains("d-none")) { close(); return; } - if (!editor) { - editor = buildTagEditor(d.doc_id, editorId, { onTags: list => renderTagChips(chips, list), onClose: close }); - row.appendChild(editor.node); - toggle.setAttribute("aria-controls", editorId); - } - editor.node.classList.remove("d-none"); - toggle.setAttribute("aria-expanded", "true"); - editor.focus(); - editor.load(); - }); - return row; -} - /** * Copy text to the clipboard with a fallback for non-secure contexts. * navigator.clipboard is undefined on plain-HTTP non-localhost origins @@ -667,11 +409,6 @@ function buildResultCard(d, queryId, order) { } li.appendChild(info); - - // --- div.tags > chips + (add-tag toggle + inline editor)? (theme extra; features.user_tag) --- - // Its own row rather than part of .info, which phones hide. - const tagRow = buildTagRow(d, idx0); - if (tagRow) li.appendChild(tagRow); return li; } @@ -980,33 +717,25 @@ function exQClauses() { } /** - * Keep the address bar's start=, ex_q= and fields.tag= in step with state.start, the facet - * selections and the tag filter, as the JSP paging and facet links did, so reload, - * back/forward and a shared link land on the same page with the same filters (runFromUrl - * reads them back). fields.tag is the only field filter kept here: it is set by the tag - * facet and chips, while the other fields.* come from the URL or default labels that are - * deliberately not written back. + * Keep the address bar's start= and ex_q= in step with state.start and the facet + * selections, as the JSP paging and facet links did, so reload, back/forward and a + * shared link land on the same page with the same filters (runFromUrl reads them + * back). * * @param {boolean} push - add a history entry (paging) instead of correcting the * current one (a filter change resetting to the first page) */ function syncUrlParams(push) { const params = new URLSearchParams(location.search); - const sameList = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]); const clauses = exQClauses(); - const sameExQ = sameList(params.getAll("ex_q"), clauses); - const tags = Array.isArray(state.fields.tag) ? state.fields.tag : []; - const sameTags = sameList(params.getAll("fields.tag"), tags); - if ((Number(params.get("start")) || 0) === state.start && sameExQ && sameTags) return; + const current = params.getAll("ex_q"); + const sameExQ = current.length === clauses.length && current.every((v, i) => v === clauses[i]); + if ((Number(params.get("start")) || 0) === state.start && sameExQ) return; if (state.start > 0) params.set("start", String(state.start)); else params.delete("start"); if (!sameExQ) { params.delete("ex_q"); clauses.forEach(v => params.append("ex_q", v)); } - if (!sameTags) { - params.delete("fields.tag"); - tags.forEach(v => params.append("fields.tag", v)); - } const qs = params.toString(); const url = location.pathname + (qs ? "?" + qs : ""); if (push) history.pushState(null, "", url); else history.replaceState(null, "", url); @@ -1053,9 +782,8 @@ function buildSearchParams() { // every configured facet-query view (timestamp / size / filetype ranges). Without // these the API returns no facet data, so the query-view groups render empty and // the sidebar is effectively dead (JSP parity: query.facet.fields + .queries). - // Shared user tags add the "tag" field facet. const cfgFacet = api.getConfig() || {}; - params["facet.field"] = userTagEnabled() ? ["label", "tag"] : ["label"]; + params["facet.field"] = ["label"]; const facetQueryValues = []; (cfgFacet.facet_views || []).forEach(v => (v.queries || []).forEach(qy => { if (qy && qy.value) facetQueryValues.push(qy.value); })); @@ -1114,7 +842,6 @@ async function runSearch() { warningEl.classList.add("d-none"); } } - collectTagNames(env); renderResults(env); renderPagination(env); const labels = await loadLabels(); @@ -1868,10 +1595,7 @@ export function attach() { // URL's ex_q clauses (sidebar facet selections and advanced-search conditions) so that // changing the sort does not silently drop a selected label. Only a new query from the // header form starts over without them. - // The tag filter of the facet and the result chips is kept for the same reason. - const current = new URLSearchParams(location.search); - current.getAll("ex_q").forEach(v => params.append("ex_q", v)); - current.getAll("fields.tag").forEach(v => params.append("fields.tag", v)); + new URLSearchParams(location.search).getAll("ex_q").forEach(v => params.append("ex_q", v)); navigate("search?" + params.toString()); }); } @@ -1973,18 +1697,17 @@ async function loadLabels() { } /** - * Build a generic facet group for field-value facets (label, tag, dynamic fields). - * Each entry click toggles the value in state[store][fieldKey]: state.facets (sent as - * ex_q clauses) by default, or state.fields (sent as fields.* params) for the tag facet. + * Build a generic facet group for field-value facets (label, dynamic fields). + * Each entry click toggles the value in state.facets[fieldKey]. */ -function buildFacetGroup(title, entries, fieldKey, store = "facets") { +function buildFacetGroup(title, entries, fieldKey) { // Tag-parity with searchResults.jsp facet group: // ul.list-group.mb-2 > li.list-group-item.text-uppercase(title) // + li.list-group-item > a > span.badge.rounded-pill.text-bg-secondary.float-end const ul = el("ul", { className: "list-group mb-2" }); ul.appendChild(el("li", { className: "list-group-item text-uppercase", text: title })); entries.forEach(entry => { - const active = (state[store][fieldKey] || []).includes(entry.value); + const active = (state.facets[fieldKey] || []).includes(entry.value); const li = el("li", { className: "list-group-item" + (active ? " active" : "") }); const a = el("a", { attrs: { href: "#" } }); a.appendChild(document.createTextNode(entry.labelText + " ")); @@ -1993,8 +1716,8 @@ function buildFacetGroup(title, entries, fieldKey, store = "facets") { } a.addEventListener("click", ev => { ev.preventDefault(); - state[store][fieldKey] = state[store][fieldKey] || []; - const arr = state[store][fieldKey]; + state.facets[fieldKey] = state.facets[fieldKey] || []; + const arr = state.facets[fieldKey]; const idx = arr.indexOf(entry.value); if (idx >= 0) arr.splice(idx, 1); else arr.push(entry.value); state.start = 0; @@ -2110,30 +1833,16 @@ function renderFacets(env, labels) { } } - // 2. Shared user tags (features.user_tag) — values come already filtered to the tags the user - // may see, each with its name as label (the value is an opaque id). Unlike the label facet, a click filters through fields.tag (the API's tag - // filter, ORing the selected tags); a selected tag stays listed so it can be cleared. - const tagField = userTagEnabled() ? facetField.find(f => f.name === "tag") : null; - if (tagField) { - const selectedTags = state.fields.tag || []; - const entries = (tagField.result || []) - .filter(r => r && r.value && (Number(r.count) > 0 || selectedTags.includes(r.value))) - .map(r => ({ labelText: String(r.label || r.value), value: r.value, count: r.count })); - if (entries.length > 0) { - body.appendChild(buildFacetGroup(t("tag.title"), entries, "tag", "fields")); - } - } - - // 3. Dynamic facet fields from API (excluding filetype, label and tag — rendered separately) + // 2. Dynamic facet fields from API (excluding filetype and label — rendered separately) for (const field of facetField) { - if (field.name === "filetype" || field.name === "label" || field.name === "tag") continue; + if (field.name === "filetype" || field.name === "label") continue; const entries = (field.result || []).map(r => ({ labelText: r.value, value: r.value, count: r.count })); if (entries.length > 0) { body.appendChild(buildFacetGroup(field.name, entries, field.name)); } } - // 4. Server-driven facet query views (timestamp ranges, size ranges, filetype + // 3. Server-driven facet query views (timestamp ranges, size ranges, filetype // ranges, etc.) (SRCH-4). The filetype group is one of these query views // (filetype:html, filetype:word, …), matching the JSP sidebar — so there is // no separate field-based filetype group (that produced a duplicate @@ -2212,8 +1921,7 @@ function renderActiveChips() { for (const [field, valueSet] of Object.entries(chipFieldSets)) { valueSet.forEach(v => chips.push({ - // A tag reads as its name rather than "tag: ". - label: field === "tag" ? tagNameOf(v) : field + ": " + v, + label: field + ": " + v, remove: () => { // Remove from whichever store(s) hold this value. if (state.facets[field]) { @@ -2586,7 +2294,13 @@ async function toggleFavorite(docId, btn, queryId) { // expired between page load and the click) *before* the auth check runs, so a // guest click can surface as 403 instead of 401. Logging in through the modal // issues a fresh session + token, so treat both the same. - if (isAuthError(e)) showLoginModal(); + if (e.code === "auth_required" || e.code === "AUTH_REQUIRED" || e.httpStatus === 401 || e.httpStatus === 403) { + if (!window.bootstrap || !bootstrap.Modal) { + console.warn("[fess] bootstrap not loaded; skipping modal show"); + } else { + bootstrap.Modal.getOrCreateInstance(document.getElementById("login-modal")).show(); + } + } } } diff --git a/src/main/webapp/themes/bootstrap/assets/styles.css b/src/main/webapp/themes/bootstrap/assets/styles.css index 025497362b..63f6a6f9d1 100644 --- a/src/main/webapp/themes/bootstrap/assets/styles.css +++ b/src/main/webapp/themes/bootstrap/assets/styles.css @@ -92,13 +92,6 @@ body { padding-top: 4.5rem; } .result-site-row { display: inline-flex; align-items: center; gap: .25rem; } /* Favorite count */ .favorite-count { font-size: .75rem; color: var(--bs-tertiary-color); margin-left: .2rem; vertical-align: middle; } -/* Shared user tags: chip row under the info line, and the inline editor on its own line */ -#result .tags { display: flex; flex-wrap: wrap; align-items: center; gap: .25rem; font-size: 80%; margin-top: .25rem; } -.tag-chip { background: var(--bs-secondary-bg-subtle); color: var(--bs-secondary-text-emphasis); border: 1px solid var(--bs-secondary-border-subtle); font-weight: 400; } -button.tag-chip { cursor: pointer; } -button.tag-chip:hover { background: var(--bs-secondary-bg); } -.tag-remove { width: .65em; height: .65em; margin: 0; padding: 0; background-size: .65em; flex-shrink: 0; } -.tag-editor { flex-basis: 100%; max-width: 36rem; } /* Brand logo (D.1) */ .brand-logo { height: 24px; } /* Facet item keyboard accessibility (C.13) */ diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.de.json b/src/main/webapp/themes/bootstrap/i18n/messages.de.json index fd4c05893a..a825efb4e5 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.de.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.de.json @@ -26,13 +26,6 @@ "result.click_count": "{n} Aufrufe", "result.owner": "Besitzer: {name}", "result.last_modifier": "Zuletzt geändert von: {name}", - "tag.title": "Tags", - "tag.add": "Tag hinzufügen", - "tag.name": "Tag-Name", - "tag.add_button": "Hinzufügen", - "tag.remove": "{name} aus meinen Tags entfernen", - "tag.empty": "Noch keine Tags.", - "tag.error": "Die Tags konnten nicht aktualisiert werden.", "auth.login": "Anmelden", "auth.logout": "Abmelden", "auth.username": "Benutzername", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.en.json b/src/main/webapp/themes/bootstrap/i18n/messages.en.json index f7c31f3a01..f9401a0fc6 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.en.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.en.json @@ -26,13 +26,6 @@ "result.click_count": "{n} views", "result.owner": "Owner: {name}", "result.last_modifier": "Last modified by: {name}", - "tag.title": "Tags", - "tag.add": "Add tag", - "tag.name": "Tag name", - "tag.add_button": "Add", - "tag.remove": "Remove {name} from my tags", - "tag.empty": "No tags yet.", - "tag.error": "Could not update the tags.", "auth.login": "Login", "auth.logout": "Logout", "auth.username": "Username", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.es.json b/src/main/webapp/themes/bootstrap/i18n/messages.es.json index aed81ae15f..3b1316a989 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.es.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.es.json @@ -26,13 +26,6 @@ "result.click_count": "{n} vistas", "result.owner": "Propietario: {name}", "result.last_modifier": "Última modificación por: {name}", - "tag.title": "Etiquetas", - "tag.add": "Añadir etiqueta", - "tag.name": "Nombre de la etiqueta", - "tag.add_button": "Añadir", - "tag.remove": "Quitar {name} de mis etiquetas", - "tag.empty": "Todavía no hay etiquetas.", - "tag.error": "No se pudieron actualizar las etiquetas.", "auth.login": "Iniciar sesión", "auth.logout": "Cerrar sesión", "auth.username": "Nombre de usuario", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.fr.json b/src/main/webapp/themes/bootstrap/i18n/messages.fr.json index ba6d25bd98..23db2d9e08 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.fr.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.fr.json @@ -26,13 +26,6 @@ "result.click_count": "{n} vues", "result.owner": "Propriétaire : {name}", "result.last_modifier": "Dernière modification par : {name}", - "tag.title": "Tags", - "tag.add": "Ajouter un tag", - "tag.name": "Nom du tag", - "tag.add_button": "Ajouter", - "tag.remove": "Retirer {name} de mes tags", - "tag.empty": "Aucun tag pour le moment.", - "tag.error": "Impossible de mettre à jour les tags.", "auth.login": "Connexion", "auth.logout": "Déconnexion", "auth.username": "Nom d'utilisateur", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.hi.json b/src/main/webapp/themes/bootstrap/i18n/messages.hi.json index 842a6a3401..8eccf17fdd 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.hi.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.hi.json @@ -26,13 +26,6 @@ "result.click_count": "{n} दृश्य", "result.owner": "स्वामी: {name}", "result.last_modifier": "अंतिम संशोधनकर्ता: {name}", - "tag.title": "टैग", - "tag.add": "टैग जोड़ें", - "tag.name": "टैग का नाम", - "tag.add_button": "जोड़ें", - "tag.remove": "{name} को मेरे टैग से हटाएँ", - "tag.empty": "अभी तक कोई टैग नहीं है।", - "tag.error": "टैग अपडेट नहीं किए जा सके।", "auth.login": "लॉग इन करें", "auth.logout": "लॉग आउट करें", "auth.username": "उपयोगकर्ता नाम", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.id.json b/src/main/webapp/themes/bootstrap/i18n/messages.id.json index ecfae8e89a..b91beb0858 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.id.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.id.json @@ -26,13 +26,6 @@ "result.click_count": "{n} tayangan", "result.owner": "Pemilik: {name}", "result.last_modifier": "Terakhir diubah oleh: {name}", - "tag.title": "Tag", - "tag.add": "Tambah tag", - "tag.name": "Nama tag", - "tag.add_button": "Tambah", - "tag.remove": "Hapus {name} dari tag saya", - "tag.empty": "Belum ada tag.", - "tag.error": "Tidak dapat memperbarui tag.", "auth.login": "Masuk", "auth.logout": "Keluar", "auth.username": "Nama pengguna", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.it.json b/src/main/webapp/themes/bootstrap/i18n/messages.it.json index bbdba780e8..07fe84e1d6 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.it.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.it.json @@ -26,13 +26,6 @@ "result.click_count": "{n} visualizzazioni", "result.owner": "Proprietario: {name}", "result.last_modifier": "Ultima modifica di: {name}", - "tag.title": "Tag", - "tag.add": "Aggiungi tag", - "tag.name": "Nome del tag", - "tag.add_button": "Aggiungi", - "tag.remove": "Rimuovi {name} dai miei tag", - "tag.empty": "Ancora nessun tag.", - "tag.error": "Impossibile aggiornare i tag.", "auth.login": "Accedi", "auth.logout": "Esci", "auth.username": "Nome utente", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.ja.json b/src/main/webapp/themes/bootstrap/i18n/messages.ja.json index a6cc605f21..3fdfbabf8a 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.ja.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.ja.json @@ -26,13 +26,6 @@ "result.click_count": "{n}回閲覧", "result.owner": "所有者: {name}", "result.last_modifier": "最終更新者: {name}", - "tag.title": "タグ", - "tag.add": "タグを追加", - "tag.name": "タグ名", - "tag.add_button": "追加", - "tag.remove": "{name} を自分のタグから外す", - "tag.empty": "タグはまだありません。", - "tag.error": "タグを更新できませんでした。", "auth.login": "ログイン", "auth.logout": "ログアウト", "auth.username": "ユーザー名", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.ko.json b/src/main/webapp/themes/bootstrap/i18n/messages.ko.json index 38f17cdd4a..c324e52973 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.ko.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.ko.json @@ -26,13 +26,6 @@ "result.click_count": "{n}회 조회", "result.owner": "소유자: {name}", "result.last_modifier": "최종 수정자: {name}", - "tag.title": "태그", - "tag.add": "태그 추가", - "tag.name": "태그 이름", - "tag.add_button": "추가", - "tag.remove": "내 태그에서 {name} 제거", - "tag.empty": "아직 태그가 없습니다.", - "tag.error": "태그를 업데이트할 수 없습니다.", "auth.login": "로그인", "auth.logout": "로그아웃", "auth.username": "사용자명", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.nl.json b/src/main/webapp/themes/bootstrap/i18n/messages.nl.json index 87d900307a..77e8519d4a 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.nl.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.nl.json @@ -26,13 +26,6 @@ "result.click_count": "{n} weergaven", "result.owner": "Eigenaar: {name}", "result.last_modifier": "Laatst gewijzigd door: {name}", - "tag.title": "Tags", - "tag.add": "Tag toevoegen", - "tag.name": "Tagnaam", - "tag.add_button": "Toevoegen", - "tag.remove": "{name} uit mijn tags verwijderen", - "tag.empty": "Nog geen tags.", - "tag.error": "De tags konden niet worden bijgewerkt.", "auth.login": "Inloggen", "auth.logout": "Uitloggen", "auth.username": "Gebruikersnaam", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.pl.json b/src/main/webapp/themes/bootstrap/i18n/messages.pl.json index ceea68eda1..dcf8cbd288 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.pl.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.pl.json @@ -26,13 +26,6 @@ "result.click_count": "{n} wyświetleń", "result.owner": "Właściciel: {name}", "result.last_modifier": "Ostatnio zmodyfikowane przez: {name}", - "tag.title": "Tagi", - "tag.add": "Dodaj tag", - "tag.name": "Nazwa tagu", - "tag.add_button": "Dodaj", - "tag.remove": "Usuń {name} z moich tagów", - "tag.empty": "Brak tagów.", - "tag.error": "Nie udało się zaktualizować tagów.", "auth.login": "Zaloguj się", "auth.logout": "Wyloguj się", "auth.username": "Nazwa użytkownika", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.pt-BR.json b/src/main/webapp/themes/bootstrap/i18n/messages.pt-BR.json index 8faa369745..9d58be43ea 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.pt-BR.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.pt-BR.json @@ -26,13 +26,6 @@ "result.click_count": "{n} visualizações", "result.owner": "Proprietário: {name}", "result.last_modifier": "Última modificação por: {name}", - "tag.title": "Tags", - "tag.add": "Adicionar tag", - "tag.name": "Nome da tag", - "tag.add_button": "Adicionar", - "tag.remove": "Remover {name} das minhas tags", - "tag.empty": "Ainda não há tags.", - "tag.error": "Não foi possível atualizar as tags.", "auth.login": "Login", "auth.logout": "Logout", "auth.username": "Nome de usuário", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.ru.json b/src/main/webapp/themes/bootstrap/i18n/messages.ru.json index 89555bba0e..d7eb7e671f 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.ru.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.ru.json @@ -26,13 +26,6 @@ "result.click_count": "{n} просмотров", "result.owner": "Владелец: {name}", "result.last_modifier": "Автор последнего изменения: {name}", - "tag.title": "Теги", - "tag.add": "Добавить тег", - "tag.name": "Название тега", - "tag.add_button": "Добавить", - "tag.remove": "Удалить {name} из моих тегов", - "tag.empty": "Тегов пока нет.", - "tag.error": "Не удалось обновить теги.", "auth.login": "Войти", "auth.logout": "Выйти", "auth.username": "Имя пользователя", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.tr.json b/src/main/webapp/themes/bootstrap/i18n/messages.tr.json index f7df3e4cf1..2d15b5ea41 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.tr.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.tr.json @@ -26,13 +26,6 @@ "result.click_count": "{n} görüntüleme", "result.owner": "Sahip: {name}", "result.last_modifier": "Son değiştiren: {name}", - "tag.title": "Etiketler", - "tag.add": "Etiket ekle", - "tag.name": "Etiket adı", - "tag.add_button": "Ekle", - "tag.remove": "{name} etiketini etiketlerimden kaldır", - "tag.empty": "Henüz etiket yok.", - "tag.error": "Etiketler güncellenemedi.", "auth.login": "Giriş yap", "auth.logout": "Çıkış yap", "auth.username": "Kullanıcı adı", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.zh-CN.json b/src/main/webapp/themes/bootstrap/i18n/messages.zh-CN.json index e8a3681ddf..fdaf88ceb9 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.zh-CN.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.zh-CN.json @@ -26,13 +26,6 @@ "result.click_count": "{n}次浏览", "result.owner": "所有者:{name}", "result.last_modifier": "最后修改者:{name}", - "tag.title": "标签", - "tag.add": "添加标签", - "tag.name": "标签名称", - "tag.add_button": "添加", - "tag.remove": "从我的标签中移除 {name}", - "tag.empty": "还没有标签。", - "tag.error": "无法更新标签。", "auth.login": "登录", "auth.logout": "注销", "auth.username": "用户名", diff --git a/src/main/webapp/themes/bootstrap/i18n/messages.zh-TW.json b/src/main/webapp/themes/bootstrap/i18n/messages.zh-TW.json index 3609e675cd..917110cbf4 100644 --- a/src/main/webapp/themes/bootstrap/i18n/messages.zh-TW.json +++ b/src/main/webapp/themes/bootstrap/i18n/messages.zh-TW.json @@ -26,13 +26,6 @@ "result.click_count": "{n}次瀏覽", "result.owner": "擁有者:{name}", "result.last_modifier": "最後修改者:{name}", - "tag.title": "標籤", - "tag.add": "新增標籤", - "tag.name": "標籤名稱", - "tag.add_button": "新增", - "tag.remove": "從我的標籤中移除 {name}", - "tag.empty": "尚無標籤。", - "tag.error": "無法更新標籤。", "auth.login": "登入", "auth.logout": "登出", "auth.username": "使用者名稱", diff --git a/src/main/webapp/themes/bootstrap/theme.yml b/src/main/webapp/themes/bootstrap/theme.yml index cc76b363d3..6254bd2f80 100644 --- a/src/main/webapp/themes/bootstrap/theme.yml +++ b/src/main/webapp/themes/bootstrap/theme.yml @@ -2,7 +2,7 @@ apiVersion: fess.codelibs.org/v1 kind: StaticTheme name: bootstrap displayName: "Bootstrap" -version: "1.5.0" +version: "1.4.0" author: "CodeLibs Project" description: "Reference static theme for Fess. Bootstrap 5-based vanilla-JS SPA exercising /api/v2/*." license: "Apache-2.0" diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java index fd3387eccd..7acb3e3f20 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java @@ -144,57 +144,6 @@ public void test_postFavoriteRejectedWithoutCsrfToken_returns403() throws Except assertTrue(body.contains("\"code\":\"forbidden\""), body); } - @Test - public void test_writeDocumentTagsRejectedWithoutCsrfToken_returns403() throws Exception { - for (final String method : new String[] { "POST", "DELETE" }) { - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final java.util.List calls = recordDocumentTagsCalls(m); - final CapturingResponse res = new CapturingResponse(); - m.process(new StubRequest("/api/v2/documents/abc123/tags").withMethod(method), res, new NopChain()); - assertEquals(403, res.status, method); - assertTrue(res.body().contains("\"code\":\"forbidden\""), res.body()); - assertTrue(calls.isEmpty(), method + " must be stopped before the handler"); - } - } - - @Test - public void test_writeDocumentTagsWithValidCsrfToken_reachesTheHandler() throws Exception { - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final java.util.List calls = recordDocumentTagsCalls(m); - for (final String method : new String[] { "POST", "DELETE" }) { - final StubSession session = new StubSession(); - session.setAttribute(SessionCsrfTokenManager.SESSION_ATTR, "the-valid-token"); - final CapturingResponse res = new CapturingResponse(); - m.process(new StubRequest("/api/v2/documents/abc123/tags").withMethod(method) - .withSession(session) - .withHeader("X-Fess-CSRF-Token", "the-valid-token"), res, new NopChain()); - assertFalse(res.body().contains("\"code\":\"forbidden\""), res.body()); - } - assertEquals(java.util.List.of("POST abc123", "DELETE abc123"), calls); - } - - @Test - public void test_getDocumentTagsWithoutTokenIsExempt() throws Exception { - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final java.util.List calls = recordDocumentTagsCalls(m); - final CapturingResponse res = new CapturingResponse(); - m.process(new StubRequest("/api/v2/documents/abc123/tags"), res, new NopChain()); - assertFalse(res.body().contains("\"code\":\"forbidden\""), res.body()); - assertEquals(java.util.List.of("GET abc123"), calls); - } - - /** Replaces the tags handler with one that records "METHOD docId" for each request it receives. */ - private static java.util.List recordDocumentTagsCalls(final SearchApiV2Manager m) { - final java.util.List calls = new java.util.ArrayList<>(); - m.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { - @Override - public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException { - calls.add(req.getMethod() + " " + docId); - } - }; - return calls; - } - @Test public void test_postChatRejectedWithoutCsrfToken_returns403() throws Exception { final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java index b6ba94ee95..ea85596f38 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java @@ -138,24 +138,6 @@ public void test_indexDerivedEndpoints_areRejectedForAnonymousWhenLoginRequired( } } - @Test - public void test_documentTags_isRejectedForAnonymousWhenLoginRequired() throws Exception { - setLoginRequired(true); - final boolean[] called = { false }; - final SearchApiV2Manager manager = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - manager.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { - @Override - public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException { - called[0] = true; - } - }; - final CapturingResponse res = new CapturingResponse(); - manager.process(request("/api/v2/documents/abc123/tags"), res, nopChain()); - assertEquals(401, res.status); - assertTrue(res.body().contains("\"code\":\"auth_required\""), res.body()); - assertFalse(called[0], "the tags handler must not run for an anonymous caller"); - } - @Test public void test_health_staysReachableWhenLoginRequired() throws Exception { setLoginRequired(true); @@ -243,8 +225,7 @@ public void test_search_isRejectedWithExpiredAccessTokenWhenLoginRequired() thro public void test_sessionOnlyEndpoints_stayGatedWithValidAccessTokenWhenLoginRequired() throws Exception { setLoginRequired(true); for (final String path : new String[] { "/api/v2/chat", "/api/v2/favorites", "/api/v2/search-history", "/api/v2/click", - "/api/v2/documents/abc123/tags", "/api/v2/cache/abc123", "/api/v2/related-queries", "/api/v2/related-content", - "/api/v2/auth/password" }) { + "/api/v2/cache/abc123", "/api/v2/related-queries", "/api/v2/related-content", "/api/v2/auth/password" }) { final CapturingResponse res = processWithTokens(path, "Bearer " + VALID_TOKEN); assertEquals(401, res.status, path); assertTrue(res.body().contains("login required"), path + " -> " + res.body()); diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java index 476429b33f..2c921e209e 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java @@ -244,54 +244,6 @@ public void test_process_documentsFavoriteRejectsMalformedDocId() throws Excepti assertTrue(body.contains("invalid doc_id"), body); } - @Test - public void test_process_documentTagsRoutesToDocumentTagsHandler() throws Exception { - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final java.util.List docIds = new java.util.ArrayList<>(); - m.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { - @Override - public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException { - docIds.add(docId); - } - }; - m.process(new StubRequest("/api/v2/documents/abc123/tags"), new CapturingResponse(), new NopChain()); - // "all" and "export" are only matched exactly, so they are plain doc ids here. - m.process(new StubRequest("/api/v2/documents/all/tags"), new CapturingResponse(), new NopChain()); - assertEquals(java.util.List.of("abc123", "all"), docIds); - } - - @Test - public void test_process_documentTagsWithoutADocIdIsNotFound() throws Exception { - // "/documents/tags" satisfies both ends of the tags pattern and leaves no doc id between them. - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final boolean[] called = { false }; - m.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { - @Override - public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId) throws IOException { - called[0] = true; - } - }; - final CapturingResponse res = new CapturingResponse(); - m.process(new StubRequest("/api/v2/documents/tags"), res, new NopChain()); - assertFalse(called[0], "/documents/tags must not reach the documentTagsHandler"); - assertEquals(404, res.status); - final String body = res.body(); - assertTrue(body.contains("\"code\":\"not_found\""), body); - assertTrue(body.contains("unknown action on document"), body); - } - - @Test - public void test_process_documentTagsRejectsMalformedDocId() throws Exception { - // Reaches the real handler, which validates the doc id before it needs any other component. - final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); - final CapturingResponse res = new CapturingResponse(); - m.process(new StubRequest("/api/v2/documents/has spaces/tags"), res, new NopChain()); - assertEquals(400, res.status); - final String body = res.body(); - assertTrue(body.contains("\"code\":\"invalid_request\""), body); - assertTrue(body.contains("invalid doc_id"), body); - } - @Test public void test_process_labelsRejectsPostAtCsrfGate() throws Exception { // Per spec §7.3, an anonymous client POSTing to a GET-only endpoint like /labels diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java index e7d71605c8..8cd9df9442 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java @@ -20,7 +20,6 @@ import org.codelibs.fess.api.v2.handlers.ChatSessionClearHandler; import org.codelibs.fess.api.v2.handlers.ChatStreamHandler; import org.codelibs.fess.api.v2.handlers.ClickHandler; -import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler; import org.codelibs.fess.api.v2.handlers.ExportSearchHandler; import org.codelibs.fess.api.v2.handlers.FavoriteGetHandler; import org.codelibs.fess.api.v2.handlers.FavoritePostHandler; @@ -84,7 +83,6 @@ static SearchApiV2Manager newManagerWithHandlers() { m.favoriteGetHandler = new FavoriteGetHandler(); m.favoritePostHandler = new FavoritePostHandler(); m.favoritesListHandler = new FavoritesListHandler(); - m.documentTagsHandler = new DocumentTagsHandler(); m.searchHistoryHandler = new SearchHistoryHandler(); m.meHandler = new MeHandler(); m.logoutHandler = new LogoutHandler(); diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java index a32c294379..06fb29d07e 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java @@ -68,7 +68,7 @@ public class CsrfRequirementCompleteCoverageTest { * Must equal {@code ENDPOINT_DECISIONS.size()}. Increment when adding a new endpoint * so that the mismatch causes a deliberate compile-time / test-time notice. */ - private static final int EXPECTED_ENTRY_COUNT = 19; + private static final int EXPECTED_ENTRY_COUNT = 18; static { // LinkedHashMap preserves insertion order for readable failure messages. @@ -98,8 +98,6 @@ public class CsrfRequirementCompleteCoverageTest { m.put("/click", true); // POST, CSRF required // /documents/{id}/favorite is a pattern — tested via representative docId m.put("/documents/abc123/favorite", true); // POST, CSRF required - // /documents/{id}/tags is a pattern — GET lists, POST adds and DELETE removes a tag - m.put("/documents/abc123/tags", true); // POST/DELETE, CSRF required m.put("/favorites", true); // GET only — secure default (CSRF required if called via POST) m.put("/search-history", true); // GET only — secure default (CSRF required if called via POST) @@ -148,8 +146,8 @@ public void test_everyEndpointHasDeliberateCsrfDecision() { public void test_stateChangingEndpointsAreAllCsrfRequired() { // Secondary assertion: all paths that POST/DELETE actual state changes (auth/logout, // auth/password, click, favorite, chat, chat/stream, chat/sessions) must require CSRF. - final List stateChangingPost = Arrays.asList("/auth/logout", "/auth/password", "/click", "/documents/abc123/favorite", - "/documents/abc123/tags", "/chat", "/chat/stream"); + final List stateChangingPost = + Arrays.asList("/auth/logout", "/auth/password", "/click", "/documents/abc123/favorite", "/chat", "/chat/stream"); for (final String path : stateChangingPost) { assertTrue(new CsrfRequirement().requiresCsrf(path, "POST"), "State-changing endpoint must require CSRF token: POST " + path); } @@ -168,22 +166,6 @@ public void test_deleteChatSessions_requiresCsrf() { "DELETE /chat/sessions/ (trailing slash) must require CSRF"); } - @Test - public void test_documentTags_requiresCsrfForWritesOnly() { - // POST adds and DELETE removes a tag; GET only lists the tags. - final CsrfRequirement requirement = new CsrfRequirement(); - for (final String path : new String[] { "/documents/abc123/tags", "/documents/a-b_c/tags" }) { - assertTrue(requirement.requiresCsrf(path, "POST"), "POST " + path + " must require CSRF"); - assertTrue(requirement.requiresCsrf(path, "DELETE"), "DELETE " + path + " must require CSRF"); - assertTrue(requirement.requiresCsrf(path, "delete"), "delete " + path + " must require CSRF (case-insensitive)"); - assertTrue(requirement.requiresCsrf(path, "PUT"), "PUT " + path + " must require CSRF (secure default)"); - assertFalse(requirement.requiresCsrf(path, "GET"), "GET " + path + " must be CSRF-exempt"); - assertFalse(requirement.requiresCsrf(path, "HEAD"), "HEAD " + path + " must be CSRF-exempt"); - } - // The id-less path is not a tags endpoint (it answers not_found), but a write to it stays gated. - assertTrue(requirement.requiresCsrf("/documents/tags", "POST")); - } - @Test public void test_getChatSessions_isExemptByGetRule() { // GET on chat/sessions path must be exempt — GET is always idempotent diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java deleted file mode 100644 index 5bcdcb9b48..0000000000 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java +++ /dev/null @@ -1,916 +0,0 @@ -/* - * Copyright 2012-2025 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.api.v2.handlers; - -import java.io.ByteArrayInputStream; -import java.io.PrintWriter; -import java.io.StringWriter; -import java.lang.reflect.InvocationHandler; -import java.lang.reflect.Proxy; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; - -import org.codelibs.core.lang.StringUtil; -import org.codelibs.fess.entity.FessUser; -import org.codelibs.fess.helper.LabelTypeHelper.LabelTypeItem; -import org.codelibs.fess.helper.TagHelper; -import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.mylasta.direction.FessConfig; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.optional.OptionalThing; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; - -import jakarta.servlet.ReadListener; -import jakarta.servlet.ServletInputStream; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import tools.jackson.databind.json.JsonMapper; - -/** - * Unit tests for {@link DocumentTagsHandler}. - * - *

The label types of the kind tag are kept in memory by {@link FakeTagHelper}, which replaces the writes - * ({@code addTag}, {@code removeTag}) and the index updates ({@code addTagToDocuments}, - * {@code removeTagFromDocuments}) and records every call, while the name and permission rules - * ({@code normalizeName}, {@code toValue}, {@code toUserPermission}, {@code isMine}) stay the real ones. The handler's - * seams supply the caller, the tags the caller can see (those whose permissions meet the caller's) and the - * document.

- */ -public class DocumentTagsHandlerTest extends UnitFessTestCase { - - private static final String DOC_ID = "doc1"; - - private static final String URL = "http://example.com/page.html"; - - private static final String OTHER_URL = "http://example.com/other.html"; - - private static final String PATH = "/api/v2/documents/" + DOC_ID + "/tags"; - - private FakeTagHelper tagHelper; - - private TagFessConfig fessConfig; - - @Override - protected void setUp(final TestInfo testInfo) throws Exception { - super.setUp(testInfo); - fessConfig = new TagFessConfig(); - ComponentUtil.setFessConfig(fessConfig); - tagHelper = new FakeTagHelper(); - ComponentUtil.register(tagHelper, "tagHelper"); - } - - // =================================================================================== - // Method gate - // =========== - - @Test - public void test_unsupportedMethods_return405WithAllowHeader() throws Exception { - for (final String method : new String[] { "PUT", "PATCH", "HEAD", "OPTIONS" }) { - final StubHandler handler = new StubHandler().user("alice"); - final Response res = new Response(); - handler.handle(request(method), res.proxy(), DOC_ID); - Assertions.assertEquals(405, res.status, method); - Assertions.assertEquals("method_not_allowed", errorCode(res), method); - Assertions.assertEquals("GET, POST, DELETE", res.headers.get("Allow"), method); - Assertions.assertEquals(0, handler.documentLookups, method); - Assertions.assertEquals(0, handler.tagListLookups, method); - } - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_methodIsCaseInsensitive() throws Exception { - tagHelper.put("a", List.of(URL), "1alice"); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("get"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(List.of(tag("a", true)), tags(res)); - } - - // =================================================================================== - // Request checks - // ============== - - @Test - public void test_invalidDocId_returns400() throws Exception { - for (final String docId : new String[] { "", " ", "a b", "../x", "a/b" }) { - final StubHandler handler = new StubHandler().user("alice"); - final Response res = new Response(); - handler.handle(request("GET"), res.proxy(), docId); - Assertions.assertEquals(400, res.status, "docId=" + docId); - Assertions.assertEquals("invalid_request", errorCode(res)); - Assertions.assertEquals("invalid doc_id", errorMessage(res)); - Assertions.assertEquals(0, handler.documentLookups); - } - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_featureDisabled_returns400ForEveryMethod() throws Exception { - tagHelper.enabled = false; - final String value = tagHelper.put("x", List.of(URL), "1alice").getValue(); - for (final String method : new String[] { "GET", "POST", "DELETE" }) { - final StubHandler handler = new StubHandler().user("alice"); - final Response res = new Response(); - handler.handle(request(method).json("{\"name\":\"x\"}").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, method); - Assertions.assertEquals("invalid_request", errorCode(res), method); - Assertions.assertEquals("tag feature is not available", errorMessage(res), method); - Assertions.assertEquals(0, handler.documentLookups, method); - Assertions.assertEquals(0, handler.tagListLookups, method); - } - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_anonymousWrites_return401() throws Exception { - final String value = tagHelper.put("x", List.of(URL), "Rguest").getValue(); - for (final String user : new String[] { null, "", " " }) { - for (final String method : new String[] { "POST", "DELETE" }) { - final StubHandler handler = new StubHandler().user(user).permissions("Rguest"); - final Response res = new Response(); - handler.handle(request(method).json("{\"name\":\"x\"}").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(401, res.status, method + " user=" + user); - Assertions.assertEquals("auth_required", errorCode(res)); - Assertions.assertEquals("login required", errorMessage(res)); - Assertions.assertEquals(0, handler.documentLookups); - } - } - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_anonymousGet_listsVisibleTagsAndIsNotAddable() throws Exception { - tagHelper.put("public", List.of(URL), "1alice", "Rguest"); - tagHelper.put("private", List.of(URL), "1alice"); - final Response res = new Response(); - new StubHandler().permissions("Rguest").handle(request("GET"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - final Map payload = payload(res); - Assertions.assertEquals(DOC_ID, payload.get("doc_id")); - Assertions.assertEquals(Boolean.FALSE, payload.get("addable")); - assertFalse(payload.containsKey("added")); - assertFalse(payload.containsKey("removed")); - // Nobody is logged in, so no tag is the caller's. - Assertions.assertEquals(List.of(tag("public", false)), tags(res)); - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_documentNotVisible_returns404() throws Exception { - final String value = tagHelper.put("x", List.of(URL), "1alice").getValue(); - for (final String method : new String[] { "GET", "POST", "DELETE" }) { - final StubHandler handler = new StubHandler().user("alice"); - handler.doc = null; - final Response res = new Response(); - handler.handle(request(method).json("{\"name\":\"x\"}").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(404, res.status, method); - Assertions.assertEquals("not_found", errorCode(res)); - Assertions.assertEquals("doc not found: " + DOC_ID, errorMessage(res)); - // The document is fetched through the caller's roles, asking only for its URL. - Assertions.assertEquals(DOC_ID, handler.lastDocId); - Assertions.assertEquals(List.of("url"), List.of(handler.lastFields)); - } - Assertions.assertEquals(List.of(), tagHelper.calls); - } - - @Test - public void test_documentWithoutUrl_returns404() throws Exception { - final StubHandler handler = new StubHandler().user("alice"); - handler.doc = new HashMap<>(Map.of("title", "no url")); - final Response res = new Response(); - handler.handle(request("GET"), res.proxy(), DOC_ID); - Assertions.assertEquals(404, res.status, res.body()); - Assertions.assertEquals("not_found", errorCode(res)); - } - - @Test - public void test_unexpectedFailure_returnsFixedInternalError() throws Exception { - final StubHandler handler = new StubHandler().user("alice"); - handler.tagListFailure = new IllegalStateException("secret backend detail"); - final Response res = new Response(); - handler.handle(request("GET"), res.proxy(), DOC_ID); - Assertions.assertEquals(500, res.status, res.body()); - Assertions.assertEquals("internal_error", errorCode(res)); - assertFalse(res.body().contains("secret backend detail"), res.body()); - } - - // =================================================================================== - // POST - // ==== - - @Test - public void test_post_invalidName_returns400() throws Exception { - fessConfig.nameMaxLength = 10; - final String[] names = { "\"\"", "\" \"", "\"\\t\\n\"", "\"abcdefghijk\"", "\"a\\u0001b\"", "\"a\\u007fb\"", "\"a\\u200bb\"", - "\"a\\u202eb\"", "42", "null", "true", "[\"x\"]", "{\"x\":\"y\"}" }; - for (final String name : names) { - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":" + name + "}"), res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, name + " -> " + res.body()); - Assertions.assertEquals("invalid_request", errorCode(res), name); - Assertions.assertEquals("invalid tag name: enter 1 to 10 characters", errorMessage(res), name); - } - // A missing name and an empty body are rejected the same way. - for (final String body : new String[] { "{}", "{\"type\":\"dept\"}", "" }) { - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json(body), res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, body + " -> " + res.body()); - Assertions.assertEquals("invalid tag name: enter 1 to 10 characters", errorMessage(res), body); - } - assertNoWrites(); - } - - @Test - public void test_post_nameAtTheMaximumLength_isAccepted() throws Exception { - fessConfig.nameMaxLength = 10; - // Ten full-width letters are ten characters after NFKC; the length is counted after normalization. - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"ABCDEFGHIJ\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals("addTag(alice," + URL + ",ABCDEFGHIJ)", tagHelper.writes().get(0)); - } - - @Test - public void test_post_quotesAndBackslashesAreAllowed() throws Exception { - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"a\\\"b\\\\c\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals("addTag(alice," + URL + ",a\"b\\c)", tagHelper.writes().get(0)); - Assertions.assertEquals(List.of(tag("a\"b\\c", true)), tags(res)); - } - - @Test - public void test_post_normalizesNameAndAddsTag() throws Exception { - tagHelper.put("existing", List.of(URL), "1bob", "2dev"); - final Response res = new Response(); - new StubHandler().user("alice") - .permissions("2dev") - .handle(request("POST").json("{\"name\":\" ABC \\t def \"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - final String value = tagHelper.toValue("ABC def"); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",ABC def)", "addTagToDocuments(" + URL + "," + value + ")"), - tagHelper.writes()); - final Map payload = payload(res); - Assertions.assertEquals(DOC_ID, payload.get("doc_id")); - Assertions.assertEquals(Boolean.TRUE, payload.get("added")); - Assertions.assertEquals(Boolean.TRUE, payload.get("addable")); - // The response lists the tags after the change. - Assertions.assertEquals(List.of(tag("existing", false), tag("ABC def", true)), tags(res)); - // A new tag is a label type of the kind tag that only its creator can see. - final LabelTypeItem created = tagHelper.find(value); - Assertions.assertEquals("ABC def", created.getLabel()); - Assertions.assertEquals(List.of("1alice"), List.of(created.getPermissions())); - Assertions.assertEquals(Set.of(URL), created.getUrlSet()); - } - - @Test - public void test_post_tagOfAnotherUser_addsTheCaller() throws Exception { - // bob shared the tag with the dev group, so alice can see it and add it to the document too. - tagHelper.put("shared", List.of(OTHER_URL), "1bob", "2dev"); - final Response res = new Response(); - new StubHandler().user("alice").permissions("2dev").handle(request("POST").json("{\"name\":\"shared\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("added")); - Assertions.assertEquals(List.of(tag("shared", true)), tags(res)); - Assertions.assertEquals( - List.of("addTag(alice," + URL + ",shared)", "addTagToDocuments(" + URL + "," + tagHelper.toValue("shared") + ")"), - tagHelper.writes()); - } - - @Test - public void test_post_alreadyAdded_returnsAddedFalseWithoutUpdatingDocuments() throws Exception { - tagHelper.put("x", List.of(URL), "1alice"); - final Response res = new Response(); - // "x" normalizes to the "x" the user already added. - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\" x \"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.FALSE, payload(res).get("added")); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",x)"), tagHelper.writes()); - Assertions.assertEquals(List.of(tag("x", true)), tags(res)); - } - - @Test - public void test_post_tooManyTags_returns400() throws Exception { - fessConfig.maxDocumentTags = 2; - tagHelper.addResult = TagHelper.AddResult.TOO_MANY_TAGS; - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"c\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, res.body()); - Assertions.assertEquals("invalid_request", errorCode(res)); - Assertions.assertEquals("too many tags: a document can have up to 2 tags", errorMessage(res)); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",c)"), tagHelper.writes()); - } - - @Test - public void test_post_tooManyLabels_returns400() throws Exception { - tagHelper.addResult = TagHelper.AddResult.TOO_MANY_LABELS; - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"c\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, res.body()); - Assertions.assertEquals("invalid_request", errorCode(res)); - Assertions.assertEquals("no more tags can be created: the number of labels reached page.labeltype.max.fetch.size", - errorMessage(res)); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",c)"), tagHelper.writes()); - } - - @Test - public void test_post_documentUpdateFailure_isSwallowed() throws Exception { - tagHelper.updateFailure = new IllegalStateException("index unavailable"); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"x\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("added")); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",x)", "addTagToDocuments(" + URL + "," + tagHelper.toValue("x") + ")"), - tagHelper.writes()); - // The label type is stored, so the tag is listed; the crawler or the label updater fixes the index later. - Assertions.assertEquals(List.of(tag("x", true)), tags(res)); - } - - @Test - public void test_post_addTagFailure_returnsInternalError() throws Exception { - tagHelper.addFailure = new IllegalStateException("store failed"); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"x\"}"), res.proxy(), DOC_ID); - Assertions.assertEquals(500, res.status, res.body()); - Assertions.assertEquals("internal_error", errorCode(res)); - Assertions.assertEquals(List.of("addTag(alice," + URL + ",x)"), tagHelper.writes()); - } - - @Test - public void test_post_bodyErrors() throws Exception { - final Response wrongType = new Response(); - new StubHandler().user("alice").handle(request("POST").body("text/plain", "{\"name\":\"x\"}"), wrongType.proxy(), DOC_ID); - Assertions.assertEquals(415, wrongType.status, wrongType.body()); - Assertions.assertEquals("unsupported_media_type", errorCode(wrongType)); - - final Response malformed = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":"), malformed.proxy(), DOC_ID); - Assertions.assertEquals(400, malformed.status, malformed.body()); - Assertions.assertEquals("invalid_request", errorCode(malformed)); - - final Response tooLarge = new Response(); - new StubHandler().user("alice").handle(request("POST").json("{\"name\":\"" + "x".repeat(2100) + "\"}"), tooLarge.proxy(), DOC_ID); - Assertions.assertEquals(413, tooLarge.status, tooLarge.body()); - Assertions.assertEquals("payload_too_large", errorCode(tooLarge)); - assertNoWrites(); - } - - // =================================================================================== - // DELETE - // ====== - - @Test - public void test_delete_ownTagSharedWithOthers_keepsTheLabelType() throws Exception { - final String value = tagHelper.put("x", List.of(URL), "1alice", "1bob").getValue(); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("DELETE").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - // removeTag answered false (bob is left), so the indexed documents keep the value. - Assertions.assertEquals(List.of("removeTag(alice," + value + ")"), tagHelper.writes()); - final Map payload = payload(res); - Assertions.assertEquals(Boolean.TRUE, payload.get("removed")); - Assertions.assertEquals(Boolean.TRUE, payload.get("addable")); - // alice can no longer see the tag. - Assertions.assertEquals(List.of(), tags(res)); - Assertions.assertEquals(List.of("1bob"), List.of(tagHelper.find(value).getPermissions())); - } - - @Test - public void test_delete_lastPermission_removesTheValueFromDocuments() throws Exception { - final String value = tagHelper.put("x", List.of(URL, OTHER_URL), "1alice").getValue(); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("DELETE").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(List.of("removeTag(alice," + value + ")", "removeTagFromDocuments(" + value + ")"), tagHelper.writes()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("removed")); - Assertions.assertEquals(List.of(), tags(res)); - Assertions.assertNull(tagHelper.find(value)); - } - - @Test - public void test_delete_documentUpdateFailure_isSwallowed() throws Exception { - final String value = tagHelper.put("x", List.of(URL), "1alice").getValue(); - tagHelper.updateFailure = new IllegalStateException("index unavailable"); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("DELETE").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("removed")); - Assertions.assertEquals(List.of("removeTag(alice," + value + ")", "removeTagFromDocuments(" + value + ")"), tagHelper.writes()); - } - - @Test - public void test_delete_visibleTagOfOthers_returns403() throws Exception { - // alice sees the tag through the dev group but is not in its permissions herself. - final String value = tagHelper.put("x", List.of(URL), "1bob", "2dev").getValue(); - final Response res = new Response(); - new StubHandler().user("alice").permissions("2dev").handle(request("DELETE").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(403, res.status, res.body()); - Assertions.assertEquals("forbidden", errorCode(res)); - Assertions.assertEquals("the tag was not added by the user", errorMessage(res)); - assertNoWrites(); - Assertions.assertEquals(List.of("1bob", "2dev"), List.of(tagHelper.find(value).getPermissions())); - } - - @Test - public void test_delete_invalidValue_returns400() throws Exception { - tagHelper.put("mine", List.of(URL), "1alice"); - // A tag that exists but that alice cannot see is not revealed: it is answered like an unknown value. - final String hidden = tagHelper.put("hidden", List.of(URL), "1bob").getValue(); - final String[] values = { null, "", " ", "unknown", tagHelper.toValue("nothing"), hidden, "mine" }; - for (final String value : values) { - final StubHandler handler = new StubHandler().user("alice"); - final Response res = new Response(); - final StubRequest req = request("DELETE"); - if (value != null) { - req.param("value", value); - } - handler.handle(req, res.proxy(), DOC_ID); - Assertions.assertEquals(400, res.status, value + " -> " + res.body()); - Assertions.assertEquals("invalid_request", errorCode(res), value); - Assertions.assertEquals("invalid tag value", errorMessage(res), value); - } - assertNoWrites(); - } - - @Test - public void test_delete_ownTagOnAnotherDocument_isAllowed() throws Exception { - // The value only has to be a tag the caller can see; the handler does not require it on this document. - final String value = tagHelper.put("x", List.of(OTHER_URL), "1alice").getValue(); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("DELETE").param("value", value), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(List.of("removeTag(alice," + value + ")", "removeTagFromDocuments(" + value + ")"), tagHelper.writes()); - } - - // =================================================================================== - // GET - // === - - @Test - public void test_get_listsVisibleTagsOfTheUrlAndMarksMine() throws Exception { - tagHelper.put("own", List.of(OTHER_URL, URL), "1alice"); - tagHelper.put("group", List.of(URL), "1bob", "2dev"); - tagHelper.put("own elsewhere", List.of(OTHER_URL), "1alice"); - tagHelper.put("hidden", List.of(URL), "1carol"); - tagHelper.put("shared", List.of(URL), "1bob", "1alice", "2dev"); - // A URL that only starts with the document URL does not match. - tagHelper.put("prefix", List.of(URL + "?x=1"), "1alice"); - final Response res = new Response(); - new StubHandler().user("alice").permissions("2dev").handle(request("GET"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("addable")); - Assertions.assertEquals(List.of(tag("own", true), tag("group", false), tag("shared", true)), tags(res)); - assertFalse(res.body().contains("hidden"), res.body()); - assertNoWrites(); - } - - @Test - public void test_get_noTags_returnsAnEmptyList() throws Exception { - final Response res = new Response(); - new StubHandler().user("alice").handle(request("GET"), res.proxy(), DOC_ID); - Assertions.assertEquals(200, res.status, res.body()); - Assertions.assertEquals(Boolean.TRUE, payload(res).get("addable")); - Assertions.assertEquals(List.of(), tags(res)); - } - - @Test - public void test_get_payloadShape() throws Exception { - final String value = tagHelper.put("x", List.of(URL), "1alice").getValue(); - final Response res = new Response(); - new StubHandler().user("alice").handle(request("GET"), res.proxy(), DOC_ID); - final Map payload = payload(res); - Assertions.assertEquals(List.of("doc_id", "addable", "tags"), - payload.keySet().stream().filter(k -> List.of("doc_id", "addable", "tags", "added", "removed").contains(k)).toList()); - @SuppressWarnings("unchecked") - final Map first = ((List>) payload.get("tags")).get(0); - Assertions.assertEquals(List.of("value", "name", "mine"), List.copyOf(first.keySet())); - // The value is the SHA-256 of the name in hex. - Assertions.assertEquals(64, value.length()); - Assertions.assertEquals(value, first.get("value")); - } - - // =================================================================================== - // Helpers - // ======= - - private void assertNoWrites() { - Assertions.assertEquals(List.of(), tagHelper.writes()); - } - - /** The JSON of a tag: its value is derived from its name. */ - private Map tag(final String name, final boolean mine) { - final Map map = new LinkedHashMap<>(); - map.put("value", tagHelper.toValue(name)); - map.put("name", name); - map.put("mine", mine); - return map; - } - - @SuppressWarnings("unchecked") - private static Map envelope(final Response res) { - final Map root = JsonMapper.builder().build().readValue(res.body(), Map.class); - return (Map) root.get("response"); - } - - private static Map payload(final Response res) { - final Map envelope = envelope(res); - Assertions.assertEquals(0, envelope.get("status"), res.body()); - return envelope; - } - - @SuppressWarnings("unchecked") - private static List> tags(final Response res) { - final List> tags = new ArrayList<>(); - for (final Object item : (List) payload(res).get("tags")) { - tags.add(new LinkedHashMap<>((Map) item)); - } - return tags; - } - - @SuppressWarnings("unchecked") - private static String errorCode(final Response res) { - return (String) ((Map) envelope(res).get("error")).get("code"); - } - - @SuppressWarnings("unchecked") - private static String errorMessage(final Response res) { - return (String) ((Map) envelope(res).get("error")).get("message"); - } - - private static StubRequest request(final String method) { - return new StubRequest(method); - } - - /** - * A handler whose caller and document are set by the test. The visible tags are those of {@link FakeTagHelper} - * whose permissions include the caller's user permission or one of the extra permissions (a group or a role). - */ - private class StubHandler extends DocumentTagsHandler { - OptionalThing user = OptionalThing.empty(); - final Set extraPermissions = new LinkedHashSet<>(); - Map doc = new HashMap<>(Map.of("url", URL)); - RuntimeException tagListFailure; - int documentLookups; - int tagListLookups; - String lastDocId; - String[] lastFields; - - StubHandler user(final String name) { - user = name == null ? OptionalThing.empty() : OptionalThing.of(new FessUserBean(new StubFessUser(name))); - return this; - } - - StubHandler permissions(final String... permissions) { - extraPermissions.addAll(Arrays.asList(permissions)); - return this; - } - - void handle(final StubRequest req, final HttpServletResponse res, final String docId) throws java.io.IOException { - handle(req.proxy(), res, docId); - } - - @Override - protected Map getDocument(final String docId, final String[] fields) { - documentLookups++; - lastDocId = docId; - lastFields = fields; - return doc; - } - - @Override - protected List getTagItemList(final HttpServletRequest req) { - tagListLookups++; - if (tagListFailure != null) { - throw tagListFailure; - } - final Set callerPermissions = new LinkedHashSet<>(extraPermissions); - final String userId = user.map(FessUserBean::getUserId).orElse(null); - if (StringUtil.isNotBlank(userId)) { - callerPermissions.add(tagHelper.toUserPermission(userId)); - } - return tagHelper.store.stream() - .filter(item -> Arrays.stream(item.getPermissions()).anyMatch(callerPermissions::contains)) - .toList(); - } - - @Override - protected OptionalThing getUserBean() { - return user; - } - } - - /** - * Keeps the label types of the kind tag in memory. The writes and the index updates are replaced and recorded; - * the name and permission rules are the real ones. - */ - private static class FakeTagHelper extends TagHelper { - boolean enabled = true; - /** When set, addTag answers this without changing the store. */ - TagHelper.AddResult addResult; - RuntimeException addFailure; - RuntimeException updateFailure; - final List store = new ArrayList<>(); - final List calls = new ArrayList<>(); - - /** Stores a tag of the name that tags the URLs and that the permissions can see. */ - LabelTypeItem put(final String name, final List urls, final String... permissions) { - final LabelTypeItem item = new LabelTypeItem(); - item.setValue(toValue(name)); - item.setLabel(name); - item.setTag(true); - item.setPermissions(permissions); - item.setUrlSet(new LinkedHashSet<>(urls)); - store.add(item); - return item; - } - - LabelTypeItem find(final String value) { - return store.stream().filter(i -> i.getValue().equals(value)).findFirst().orElse(null); - } - - List writes() { - return calls.stream().filter(c -> c.startsWith("addTag") || c.startsWith("removeTag")).toList(); - } - - @Override - public boolean isEnabled() { - return enabled; - } - - @Override - public synchronized AddResult addTag(final String userId, final String url, final String name) { - calls.add("addTag(" + userId + "," + url + "," + name + ")"); - if (addFailure != null) { - throw addFailure; - } - if (addResult != null) { - return addResult; - } - final String permission = toUserPermission(userId); - final LabelTypeItem item = find(toValue(name)); - if (item == null) { - put(name, List.of(url), permission); - return AddResult.ADDED; - } - final List permissions = new ArrayList<>(Arrays.asList(item.getPermissions())); - if (item.getUrlSet().contains(url) && permissions.contains(permission)) { - return AddResult.ALREADY_ADDED; - } - final Set urlSet = new LinkedHashSet<>(item.getUrlSet()); - urlSet.add(url); - item.setUrlSet(urlSet); - if (!permissions.contains(permission)) { - permissions.add(permission); - } - item.setPermissions(permissions.toArray(new String[0])); - return AddResult.ADDED; - } - - @Override - public synchronized boolean removeTag(final String userId, final String value) { - calls.add("removeTag(" + userId + "," + value + ")"); - final LabelTypeItem item = find(value); - if (item == null) { - return false; - } - final List permissions = new ArrayList<>(Arrays.asList(item.getPermissions())); - if (!permissions.remove(toUserPermission(userId))) { - return false; - } - if (permissions.isEmpty()) { - store.remove(item); - return true; - } - item.setPermissions(permissions.toArray(new String[0])); - return false; - } - - @Override - public void addTagToDocuments(final String url, final String value) { - calls.add("addTagToDocuments(" + url + "," + value + ")"); - if (updateFailure != null) { - throw updateFailure; - } - } - - @Override - public void removeTagFromDocuments(final String value) { - calls.add("removeTagFromDocuments(" + value + ")"); - if (updateFailure != null) { - throw updateFailure; - } - } - } - - /** A configuration whose user tag limits are set by the test. */ - private static class TagFessConfig extends FessConfig.SimpleImpl { - private static final long serialVersionUID = 1L; - int nameMaxLength = 50; - int maxDocumentTags = 100; - - @Override - public boolean isUserTagEnabled() { - return true; - } - - @Override - public Integer getUserTagNameMaxLengthAsInteger() { - return nameMaxLength; - } - - @Override - public Integer getUserTagMaxDocumentTagsAsInteger() { - return maxDocumentTags; - } - - @Override - public String getRoleSearchUserPrefix() { - return "1"; - } - - @Override - public String getIndexFieldUrl() { - return "url"; - } - } - - /** Minimal {@link FessUser} with a name. */ - private static class StubFessUser implements FessUser { - private static final long serialVersionUID = 1L; - private final String name; - - StubFessUser(final String name) { - this.name = name; - } - - @Override - public String getName() { - return name; - } - - @Override - public String[] getRoleNames() { - return new String[0]; - } - - @Override - public String[] getGroupNames() { - return new String[0]; - } - - @Override - public String[] getPermissions() { - return new String[0]; - } - } - - /** A request built through a dynamic proxy: method, query parameters and an optional body. */ - private static class StubRequest { - private final String method; - private final Map params = new HashMap<>(); - private byte[] body = new byte[0]; - private String contentType; - - StubRequest(final String method) { - this.method = method; - } - - StubRequest param(final String name, final String value) { - params.put(name, value); - return this; - } - - StubRequest json(final String json) { - return body("application/json", json); - } - - StubRequest body(final String type, final String content) { - contentType = type; - body = content.getBytes(StandardCharsets.UTF_8); - return this; - } - - HttpServletRequest proxy() { - final ByteArrayInputStream in = new ByteArrayInputStream(body); - final ServletInputStream stream = new ServletInputStream() { - @Override - public int read() { - return in.read(); - } - - @Override - public boolean isFinished() { - return in.available() == 0; - } - - @Override - public boolean isReady() { - return true; - } - - @Override - public void setReadListener(final ReadListener listener) { - // not used - } - }; - final InvocationHandler h = (proxy, m, args) -> switch (m.getName()) { - case "getMethod" -> method; - case "getParameter" -> params.get(args[0]); - case "getContentType" -> contentType; - case "getContentLength" -> body.length; - case "getContentLengthLong" -> (long) body.length; - case "getInputStream" -> stream; - case "getLocale" -> Locale.ENGLISH; - case "getRequestURI", "getServletPath" -> PATH; - case "hashCode" -> System.identityHashCode(proxy); - case "equals" -> proxy == args[0]; - case "toString" -> "StubRequest[" + method + " " + PATH + "]"; - default -> defaultValue(m.getReturnType()); - }; - return (HttpServletRequest) Proxy.newProxyInstance(DocumentTagsHandlerTest.class.getClassLoader(), - new Class[] { HttpServletRequest.class }, h); - } - } - - /** Captures the status, the headers and the body written through a dynamic proxy. */ - private static class Response { - int status = 200; - final Map headers = new HashMap<>(); - private final StringWriter sw = new StringWriter(); - private final PrintWriter writer = new PrintWriter(sw); - - String body() { - writer.flush(); - return sw.toString(); - } - - HttpServletResponse proxy() { - final InvocationHandler h = (proxy, m, args) -> switch (m.getName()) { - case "setStatus" -> { - status = (Integer) args[0]; - yield null; - } - case "getStatus" -> status; - case "setHeader", "addHeader" -> { - headers.put((String) args[0], (String) args[1]); - yield null; - } - case "getHeader" -> headers.get(args[0]); - case "getWriter" -> writer; - case "resetBuffer" -> { - writer.flush(); - sw.getBuffer().setLength(0); - yield null; - } - case "getCharacterEncoding" -> "UTF-8"; - case "hashCode" -> System.identityHashCode(proxy); - case "equals" -> proxy == args[0]; - case "toString" -> "Response[" + status + "]"; - default -> defaultValue(m.getReturnType()); - }; - return (HttpServletResponse) Proxy.newProxyInstance(DocumentTagsHandlerTest.class.getClassLoader(), - new Class[] { HttpServletResponse.class }, h); - } - } - - private static Object defaultValue(final Class type) { - if (!type.isPrimitive()) { - return null; - } - if (type == boolean.class) { - return Boolean.FALSE; - } - if (type == int.class) { - return 0; - } - if (type == long.class) { - return 0L; - } - return null; - } -} diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java index c19fcb5006..2bfc21bc92 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java @@ -50,7 +50,7 @@ public class LoginRequirementCompleteCoverageTest { private static final Map ENDPOINT_DECISIONS; /** Must equal {@code ENDPOINT_DECISIONS.size()}. */ - private static final int EXPECTED_ENTRY_COUNT = 21; + private static final int EXPECTED_ENTRY_COUNT = 20; static { final Map m = new LinkedHashMap<>(); @@ -80,7 +80,6 @@ public class LoginRequirementCompleteCoverageTest { m.put("/favorites", true); m.put("/search-history", true); m.put("/documents/abc123/favorite", true); - m.put("/documents/abc123/tags", true); m.put("/chat", true); ENDPOINT_DECISIONS = Collections.unmodifiableMap(m); diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java index 54be779ca7..2aaf1bc3b3 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java @@ -31,13 +31,10 @@ import org.codelibs.fess.entity.SearchRenderData; import org.codelibs.fess.exception.InvalidQueryException; import org.codelibs.fess.exception.ResultOffsetExceededException; -import org.codelibs.fess.helper.LabelTypeHelper.LabelTypeItem; import org.codelibs.fess.helper.RelatedContentHelper; import org.codelibs.fess.helper.RelatedQueryHelper; import org.codelibs.fess.helper.SearchHelper; -import org.codelibs.fess.helper.TagHelper; import org.codelibs.fess.mylasta.action.FessUserBean; -import org.codelibs.fess.query.QueryFieldConfig; import org.codelibs.fess.unit.UnitFessTestCase; import org.codelibs.fess.util.ComponentUtil; import org.codelibs.fess.util.FacetResponse; @@ -225,228 +222,6 @@ public void test_buildFacetQuery_shape() { assertEquals(17L, out.get(0).get("count")); } - // ===== User tags ===== - - /** Registers the real tag rules and the real API response field set (in which the raw tag field is absent). */ - private static void registerTagComponents() { - ComponentUtil.register(new TagHelper(), "tagHelper"); - final QueryFieldConfig queryFieldConfig = new QueryFieldConfig(); - queryFieldConfig.init(); - ComponentUtil.register(queryFieldConfig, "queryFieldConfig"); - } - - /** A visible tag: a label type of the kind tag whose permissions are the given users. */ - private static LabelTypeItem tagLabel(final String value, final String name, final String... users) { - final LabelTypeItem item = new LabelTypeItem(); - item.setValue(value); - item.setLabel(name); - item.setTag(true); - item.setPermissions(java.util.Arrays.stream(users).map(u -> "1" + u).toArray(String[]::new)); - item.setUrlSet(new java.util.LinkedHashSet<>(List.of("http://example.com/"))); - return item; - } - - private static Map tagItemMap(final LabelTypeItem... items) { - final Map map = new LinkedHashMap<>(); - for (final LabelTypeItem item : items) { - map.put(item.getValue(), item); - } - return map; - } - - private static Map tagItem(final String value, final String name, final boolean mine) { - final Map item = new LinkedHashMap<>(); - item.put("value", value); - item.put("name", name); - item.put("mine", mine); - return item; - } - - @Test - public void test_filterDocuments_addsTheVisibleTags() { - registerTagComponents(); - final Map doc = new LinkedHashMap<>(); - doc.put("title", "Title"); - doc.put("url", "http://example.com/"); - doc.put("tag", new String[] { "aaa", "hidden", "bbb" }); - final Map visible = tagItemMap(tagLabel("aaa", "Alpha", "alice", "bob"), tagLabel("bbb", "Beta", "bob")); - final List> out = new SearchHandler().filterDocuments(List.of(doc), visible, "alice"); - assertEquals(1, out.size()); - final Map filtered = out.get(0); - assertEquals("Title", filtered.get("title")); - assertEquals("http://example.com/", filtered.get("url")); - // The raw field can carry the values of tags that the caller cannot see, so it never reaches the response. - assertFalse(filtered.containsKey("tag"), filtered.toString()); - assertEquals(List.of(tagItem("aaa", "Alpha", true), tagItem("bbb", "Beta", false)), filtered.get("tags")); - } - - @Test - public void test_filterDocuments_anonymousCallerOwnsNoTag() { - registerTagComponents(); - final Map doc = new LinkedHashMap<>(); - doc.put("tag", new String[] { "aaa" }); - final Map visible = tagItemMap(tagLabel("aaa", "Alpha", "alice")); - for (final String userId : new String[] { null, "", "bob" }) { - final List> out = new SearchHandler().filterDocuments(List.of(doc), visible, userId); - org.junit.jupiter.api.Assertions.assertEquals(List.of(tagItem("aaa", "Alpha", false)), out.get(0).get("tags"), - "userId=" + userId); - } - } - - @Test - public void test_filterDocuments_acceptsListAndSingleTagValues() { - registerTagComponents(); - final Map listDoc = new LinkedHashMap<>(); - listDoc.put("title", "list"); - listDoc.put("tag", List.of("aaa", "hidden")); - final Map singleDoc = new LinkedHashMap<>(); - singleDoc.put("title", "single"); - singleDoc.put("tag", "zzz"); - final Map visible = tagItemMap(tagLabel("aaa", "Alpha"), tagLabel("zzz", "Zeta", "alice")); - final List> out = new SearchHandler().filterDocuments(List.of(listDoc, singleDoc), visible, "alice"); - assertEquals(List.of(tagItem("aaa", "Alpha", false)), out.get(0).get("tags")); - assertEquals(List.of(tagItem("zzz", "Zeta", true)), out.get(1).get("tags")); - } - - @Test - public void test_filterDocuments_noTagsKeyWithoutAVisibleTag() { - registerTagComponents(); - final Map hiddenOnly = new LinkedHashMap<>(); - hiddenOnly.put("title", "hidden"); - hiddenOnly.put("tag", new String[] { "hidden" }); - final Map untagged = new LinkedHashMap<>(); - untagged.put("title", "untagged"); - final List> out = - new SearchHandler().filterDocuments(List.of(hiddenOnly, untagged), tagItemMap(tagLabel("aaa", "Alpha", "alice")), "alice"); - for (final Map filtered : out) { - assertFalse(filtered.containsKey("tags"), filtered.toString()); - assertFalse(filtered.containsKey("tag"), filtered.toString()); - } - } - - @Test - public void test_filterDocuments_emptyTagMapAddsNothing() { - // No tag helper is registered: with no visible tag (or user tags disabled) it is not consulted. - final QueryFieldConfig queryFieldConfig = new QueryFieldConfig(); - queryFieldConfig.init(); - ComponentUtil.register(queryFieldConfig, "queryFieldConfig"); - final Map doc = new LinkedHashMap<>(); - doc.put("title", "Title"); - doc.put("tag", new String[] { "aaa" }); - final List> out = new SearchHandler().filterDocuments(List.of(doc), Collections.emptyMap(), "alice"); - assertEquals(Map.of("title", "Title"), out.get(0)); - } - - private static HttpServletRequest localeRequest(final Locale locale) { - return (HttpServletRequest) java.lang.reflect.Proxy.newProxyInstance(SearchHandlerTest.class.getClassLoader(), - new Class[] { HttpServletRequest.class }, (proxy, method, args) -> "getLocale".equals(method.getName()) ? locale : null); - } - - /** Delegates to the container's configuration except for user.tag.enabled. */ - private static void setUserTagEnabled(final boolean enabled) { - final org.codelibs.fess.mylasta.direction.FessConfig real = ComponentUtil.getFessConfig(); - ComponentUtil.setFessConfig((org.codelibs.fess.mylasta.direction.FessConfig) java.lang.reflect.Proxy.newProxyInstance( - org.codelibs.fess.mylasta.direction.FessConfig.class.getClassLoader(), - new Class[] { org.codelibs.fess.mylasta.direction.FessConfig.class }, (proxy, method, args) -> { - if ("isUserTagEnabled".equals(method.getName())) { - return enabled; - } - try { - return method.invoke(real, args); - } catch (final java.lang.reflect.InvocationTargetException e) { - throw e.getCause(); - } - })); - } - - @Test - public void test_getTagItemMap_followsTheFeatureFlag() { - final List requested = new java.util.ArrayList<>(); - final LabelTypeItem alpha = tagLabel("aaa", "Alpha", "alice"); - final LabelTypeItem beta = tagLabel("bbb", "Beta"); - ComponentUtil.register(new org.codelibs.fess.helper.LabelTypeHelper() { - @Override - public List getTagItemList(final SearchRequestParams.SearchRequestType searchRequestType, - final Locale requestLocale) { - assertEquals(SearchRequestParams.SearchRequestType.JSON, searchRequestType); - requested.add(requestLocale); - return List.of(alpha, beta); - } - }, "labelTypeHelper"); - - setUserTagEnabled(false); - assertEquals(Map.of(), new SearchHandler().getTagItemMap(localeRequest(Locale.JAPANESE))); - org.junit.jupiter.api.Assertions.assertEquals(List.of(), requested, "the label types are not read while user tags are disabled"); - - setUserTagEnabled(true); - final Map map = new SearchHandler().getTagItemMap(localeRequest(Locale.JAPANESE)); - assertEquals(List.of("aaa", "bbb"), List.copyOf(map.keySet())); - assertSame(alpha, map.get("aaa")); - assertSame(beta, map.get("bbb")); - assertEquals(List.of("aaa", "bbb"), List.copyOf(new SearchHandler().getTagItemMap(localeRequest(null)).keySet())); - assertEquals(List.of(Locale.JAPANESE, Locale.ROOT), requested); - } - - @Test - public void test_getTagItemMap_failureMeansNoTags() { - setUserTagEnabled(true); - ComponentUtil.register(new org.codelibs.fess.helper.LabelTypeHelper() { - @Override - public List getTagItemList(final SearchRequestParams.SearchRequestType searchRequestType, - final Locale requestLocale) { - throw new IllegalStateException("label types unavailable"); - } - }, "labelTypeHelper"); - assertEquals(Map.of(), new SearchHandler().getTagItemMap(localeRequest(Locale.ENGLISH))); - } - - @Test - public void test_buildFacetField_tagValuesLimitedToVisibleTags() { - final TestFacetField tagField = new TestFacetField("tag"); - tagField.overrideName = "tag"; - tagField.overrideValueCountMap.put("hidden", 9L); - tagField.overrideValueCountMap.put("aaa", 3L); - tagField.overrideValueCountMap.put("bbb", 1L); - final TestFacetField labelField = new TestFacetField("label"); - labelField.overrideName = "label"; - labelField.overrideValueCountMap.put("hidden", 4L); - final TestFacetResponse fr = new TestFacetResponse(); - fr.addField(tagField); - fr.addField(labelField); - - final List> out = - new SearchHandler().buildFacetField(fr, tagItemMap(tagLabel("aaa", "Alpha", "alice"), tagLabel("bbb", "Beta"))); - assertEquals(2, out.size()); - assertEquals("tag", out.get(0).get("name")); - // A tag that the caller cannot see is not revealed by its count; a visible one carries its name. - assertEquals(List.of(Map.of("value", "aaa", "count", 3L, "label", "Alpha"), Map.of("value", "bbb", "count", 1L, "label", "Beta")), - out.get(0).get("result")); - // Only the tag facet is filtered; a value of another facet is kept and gets no label. - assertEquals("label", out.get(1).get("name")); - assertEquals(List.of(Map.of("value", "hidden", "count", 4L)), out.get(1).get("result")); - } - - @Test - public void test_buildFacetField_withoutVisibleTags_dropsEveryTagValue() { - final TestFacetField tagField = new TestFacetField("tag"); - tagField.overrideName = "tag"; - tagField.overrideValueCountMap.put("aaa", 3L); - final TestFacetField labelField = new TestFacetField("label"); - labelField.overrideName = "label"; - labelField.overrideValueCountMap.put("news", 5L); - final TestFacetResponse fr = new TestFacetResponse(); - fr.addField(tagField); - fr.addField(labelField); - - for (final List> out : List.of(new SearchHandler().buildFacetField(fr), - new SearchHandler().buildFacetField(fr, Collections.emptyMap()))) { - assertEquals(2, out.size()); - assertEquals("tag", out.get(0).get("name")); - assertEquals(List.of(), out.get(0).get("result")); - assertEquals(List.of(Map.of("value", "news", "count", 5L)), out.get(1).get("result")); - } - } - /** * Test-only FacetResponse subclass: passes {@code null} Aggregations (→ no-op * forEach in parent) and exposes the inherited protected fields directly so tests diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java index 6b8d8e61a9..dd01ecc970 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java @@ -598,45 +598,6 @@ public void test_features_searchHistory_offWhenDisabled() throws Exception { assertTrue(res.body().contains("\"search_history\":false"), res.body()); } - /** Delegates every call to the real configuration except user.tag.enabled. */ - private static void setUserTagEnabled(final boolean enabled) { - final org.codelibs.fess.mylasta.direction.FessConfig real = ComponentUtil.getFessConfig(); - ComponentUtil.setFessConfig((org.codelibs.fess.mylasta.direction.FessConfig) java.lang.reflect.Proxy.newProxyInstance( - org.codelibs.fess.mylasta.direction.FessConfig.class.getClassLoader(), - new Class[] { org.codelibs.fess.mylasta.direction.FessConfig.class }, (proxy, method, args) -> { - if ("isUserTagEnabled".equals(method.getName())) { - return enabled; - } - try { - return method.invoke(real, args); - } catch (final java.lang.reflect.InvocationTargetException e) { - throw e.getCause(); - } - })); - } - - @Test - public void test_features_userTag_offByDefault() throws Exception { - final CapturingResponse res = new CapturingResponse(); - new UiConfigHandler().handle(new StubRequest("GET", "/api/v2/ui/config").withSession(new StubSession()), res); - assertEquals(200, res.status, res.body()); - assertTrue(res.body().contains("\"user_tag\":false"), res.body()); - // The tags themselves are label types; the config does not list them. - assertFalse(res.body().contains("\"tag_types\""), res.body()); - } - - @Test - public void test_features_userTag_onWhenEnabled() throws Exception { - setUserTagEnabled(true); - final CapturingResponse res = new CapturingResponse(); - new UiConfigHandler().handle( - new StubRequest("GET", "/api/v2/ui/config").withSession(new StubSession()).withLocale(java.util.Locale.JAPANESE), res); - assertEquals(200, res.status, res.body()); - final String body = res.body(); - assertTrue(body.contains("\"user_tag\":true"), body); - assertFalse(body.contains("\"tag_types\""), body); - } - @Test public void test_features_searchExport_offByDefault() throws Exception { final CapturingResponse res = new CapturingResponse(); diff --git a/src/test/java/org/codelibs/fess/helper/LabelTypeHelperTest.java b/src/test/java/org/codelibs/fess/helper/LabelTypeHelperTest.java index 766003d3e4..920d5f3345 100644 --- a/src/test/java/org/codelibs/fess/helper/LabelTypeHelperTest.java +++ b/src/test/java/org/codelibs/fess/helper/LabelTypeHelperTest.java @@ -289,78 +289,6 @@ public void test_getMatchedLabelValueSet_multipleMatches() { } // Helper method to create test data - @Test - public void test_tagKind_listedOnlyAsTag() { - ComponentUtil.register(new RoleQueryHelper() { - @Override - public Set build(SearchRequestType searchRequestType) { - return new HashSet<>(List.of("Rguest")); - } - }, "roleQueryHelper"); - labelTypeHelper.refresh(List.of(createLabelType("lbl", null, "Rguest"), // - createLabelType("plain", LabelType.KIND_LABEL, "Rguest"), // - createLabelType("general", LabelType.KIND_TAG, "Rguest"), // - createLabelType("sales", LabelType.KIND_TAG, "Rsales"))); - - final List> labels = labelTypeHelper.getLabelTypeItemList(SearchRequestType.JSON, Locale.ROOT); - assertEquals(List.of("lbl", "plain"), labels.stream().map(m -> m.get("value")).toList()); - - final List tags = labelTypeHelper.getTagItemList(SearchRequestType.JSON, Locale.ROOT); - assertEquals(1, tags.size()); - assertEquals("general", tags.get(0).getValue()); - assertEquals("general name", tags.get(0).getLabel()); - assertEquals(Set.of("general"), labelTypeHelper.getTagValueSet(SearchRequestType.JSON, Locale.ROOT)); - } - - @Test - public void test_tagKind_noRoles() { - labelTypeHelper.refresh(List.of(createLabelType("general", LabelType.KIND_TAG, "Rguest"))); - assertTrue(labelTypeHelper.getTagValueSet(SearchRequestType.JSON, Locale.ROOT).isEmpty()); - } - - @Test - public void test_tagKind_matchesExactUrls() { - final LabelType label = createLabelType("lbl", null, "Rguest"); - label.setIncludedPaths("http://example.com/.*"); - final LabelType tag1 = createLabelType("tag1", LabelType.KIND_TAG, "1alice"); - tag1.setIncludedPaths("http://example.com/a.html\n http://example.com/b?x=1 \n\n"); - final LabelType tag2 = createLabelType("tag2", LabelType.KIND_TAG, "1bob"); - tag2.setIncludedPaths("http://example.com/.*"); - labelTypeHelper.refresh(List.of(label, tag1, tag2)); - - // a tag is not a path pattern - assertEquals(Set.of("lbl"), labelTypeHelper.getMatchedLabelValueSet("http://example.com/a.html")); - assertEquals(Set.of("tag1"), labelTypeHelper.getMatchedTagValueSet("http://example.com/a.html")); - assertEquals(Set.of("tag1"), labelTypeHelper.getMatchedTagValueSet("http://example.com/b?x=1")); - assertEquals(Set.of("tag2"), labelTypeHelper.getMatchedTagValueSet("http://example.com/.*")); - assertTrue(labelTypeHelper.getMatchedTagValueSet("http://example.com/bxx=1").isEmpty()); - assertTrue(labelTypeHelper.getMatchedTagValueSet(null).isEmpty()); - } - - @Test - public void test_toUrlSet() { - assertEquals(List.of("http://a/", "http://b/"), List.copyOf(LabelTypeHelper.toUrlSet(" http://a/\n\nhttp://b/\nhttp://a/ "))); - assertTrue(LabelTypeHelper.toUrlSet(null).isEmpty()); - assertTrue(LabelTypeHelper.toUrlSet(" ").isEmpty()); - } - - @Test - public void test_isTagKind() { - assertFalse(createLabelType("a", null, "Rguest").isTagKind()); - assertFalse(createLabelType("a", LabelType.KIND_LABEL, "Rguest").isTagKind()); - assertTrue(createLabelType("a", LabelType.KIND_TAG, "Rguest").isTagKind()); - } - - private LabelType createLabelType(final String value, final String kind, final String permission) { - final LabelType labelType = new LabelType(); - labelType.setName(value + " name"); - labelType.setValue(value); - labelType.setKind(kind); - labelType.setPermissions(new String[] { permission }); - labelType.setVirtualHost(""); - return labelType; - } - private List createTestLabelTypeList() { List labelTypeList = new ArrayList<>(); diff --git a/src/test/java/org/codelibs/fess/helper/TagHelperTest.java b/src/test/java/org/codelibs/fess/helper/TagHelperTest.java deleted file mode 100644 index 0ce07fac80..0000000000 --- a/src/test/java/org/codelibs/fess/helper/TagHelperTest.java +++ /dev/null @@ -1,255 +0,0 @@ -/* - * Copyright 2012-2025 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.helper; - -import java.util.ArrayList; -import java.util.List; -import java.util.Set; - -import org.codelibs.fess.app.service.LabelTypeService; -import org.codelibs.fess.helper.TagHelper.AddResult; -import org.codelibs.fess.opensearch.config.exentity.LabelType; -import org.codelibs.fess.unit.UnitFessTestCase; -import org.codelibs.fess.util.ComponentUtil; -import org.dbflute.optional.OptionalEntity; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestInfo; - -public class TagHelperTest extends UnitFessTestCase { - - private TagHelper tagHelper; - - private final List store = new ArrayList<>(); - - private final List deleted = new ArrayList<>(); - - private int urlTagCount; - - private int otherLabelCount; - - @Override - protected void setUp(final TestInfo testInfo) throws Exception { - super.setUp(testInfo); - ComponentUtil.register(new SystemHelper(), "systemHelper"); - ComponentUtil.register(new VirtualHostHelper() { - @Override - public String getVirtualHostKey() { - return ""; - } - }, "virtualHostHelper"); - final LabelTypeService labelTypeService = new LabelTypeService() { - @Override - public OptionalEntity getLabelTypeByValue(final String value) { - return store.stream() - .filter(l -> value.equals(l.getValue())) - .findFirst() - .map(OptionalEntity::of) - .orElseGet(OptionalEntity::empty); - } - - @Override - public List getLabelTypeList() { - final List list = new ArrayList<>(store); - for (int i = 0; i < otherLabelCount; i++) { - list.add(new LabelType()); - } - return list; - } - - @Override - public void store(final LabelType labelType) { - if (!store.contains(labelType)) { - store.add(labelType); - } - } - - @Override - public void delete(final LabelType labelType) { - store.remove(labelType); - deleted.add(labelType); - } - }; - tagHelper = new TagHelper() { - @Override - public int getNameMaxLength() { - return 10; - } - - @Override - protected int countTags(final String url) { - return urlTagCount; - } - - @Override - protected int getMaxDocumentTags() { - return 2; - } - - @Override - protected LabelTypeService getLabelTypeService() { - return labelTypeService; - } - }; - } - - @Test - public void test_toValue() { - final String value = tagHelper.toValue("提案書"); - assertEquals(64, value.length()); - // a valid label value for the admin form - assertTrue(value.matches("^[a-zA-Z0-9_]+$")); - assertEquals(value, tagHelper.toValue("提案書")); - Assertions.assertNotEquals(value, tagHelper.toValue("提案")); - } - - @Test - public void test_normalizeName() { - assertEquals("proposal", tagHelper.normalizeName("proposal")); - assertEquals("a b", tagHelper.normalizeName(" a \t\n b ")); - // NFKC: full-width alphanumerics and half-width katakana - assertEquals("ABC", tagHelper.normalizeName("ABC")); - assertEquals("カタカナ", tagHelper.normalizeName("カタカナ")); - assertEquals("1234567890", tagHelper.normalizeName("1234567890")); - assertEquals("a\"b\\c", tagHelper.normalizeName("a\"b\\c")); - - assertNull(tagHelper.normalizeName(null)); - assertNull(tagHelper.normalizeName("")); - assertNull(tagHelper.normalizeName(" ")); - assertNull(tagHelper.normalizeName("12345678901")); - assertNull(tagHelper.normalizeName("a\u0000b")); - assertNull(tagHelper.normalizeName("a\u007fb")); - assertNull(tagHelper.normalizeName("a​b")); - assertNull(tagHelper.normalizeName("a‮b")); - } - - @Test - public void test_normalizeName_countsCodePoints() { - final String name = "😀".repeat(10); - assertEquals(name, tagHelper.normalizeName(name)); - assertNull(tagHelper.normalizeName("😀".repeat(11))); - } - - @Test - public void test_addTag_createsTagVisibleToTheUser() { - assertEquals(AddResult.ADDED, tagHelper.addTag("alice", "http://example.com/a", "提案書")); - - assertEquals(1, store.size()); - final LabelType tag = store.get(0); - assertEquals("提案書", tag.getName()); - assertEquals(tagHelper.toValue("提案書"), tag.getValue()); - assertTrue(tag.isTagKind()); - assertEquals("http://example.com/a", tag.getIncludedPaths()); - Assertions.assertArrayEquals(new String[] { "1alice" }, tag.getPermissions()); - assertEquals("alice", tag.getCreatedBy()); - } - - @Test - public void test_addTag_existingTag() { - tagHelper.addTag("alice", "http://example.com/a", "提案書"); - - // the same user again - assertEquals(AddResult.ALREADY_ADDED, tagHelper.addTag("alice", "http://example.com/a", "提案書")); - // another URL - assertEquals(AddResult.ADDED, tagHelper.addTag("alice", "http://example.com/b", "提案書")); - // another user on a tagged URL joins the permissions - assertEquals(AddResult.ADDED, tagHelper.addTag("bob", "http://example.com/a", "提案書")); - - assertEquals(1, store.size()); - final LabelType tag = store.get(0); - assertEquals(Set.of("http://example.com/a", "http://example.com/b"), LabelTypeHelper.toUrlSet(tag.getIncludedPaths())); - Assertions.assertArrayEquals(new String[] { "1alice", "1bob" }, tag.getPermissions()); - } - - @Test - public void test_addTag_keepsOtherPermissions() { - final LabelType tag = new LabelType(); - tag.setName("提案書"); - tag.setValue(tagHelper.toValue("提案書")); - tag.setKind(LabelType.KIND_TAG); - tag.setIncludedPaths("http://example.com/a"); - tag.setPermissions(new String[] { "Rsales" }); - store.add(tag); - - assertEquals(AddResult.ADDED, tagHelper.addTag("alice", "http://example.com/a", "提案書")); - Assertions.assertArrayEquals(new String[] { "Rsales", "1alice" }, tag.getPermissions()); - } - - @Test - public void test_addTag_limits() { - urlTagCount = 2; - assertEquals(AddResult.TOO_MANY_TAGS, tagHelper.addTag("alice", "http://example.com/a", "a")); - assertTrue(store.isEmpty()); - - urlTagCount = 0; - // page.labeltype.max.fetch.size: label types beyond it would not be loaded - otherLabelCount = 1000; - assertEquals(AddResult.TOO_MANY_LABELS, tagHelper.addTag("alice", "http://example.com/a", "a")); - assertTrue(store.isEmpty()); - } - - @Test - public void test_addTag_labelWithTheValue() { - final LabelType label = new LabelType(); - label.setValue(tagHelper.toValue("a")); - store.add(label); - try { - tagHelper.addTag("alice", "http://example.com/a", "a"); - fail(); - } catch (final IllegalStateException e) { - // a plain label is never changed - } - } - - @Test - public void test_removeTag() { - tagHelper.addTag("alice", "http://example.com/a", "提案書"); - tagHelper.addTag("bob", "http://example.com/b", "提案書"); - final String value = tagHelper.toValue("提案書"); - - // bob is left - assertFalse(tagHelper.removeTag("alice", value)); - Assertions.assertArrayEquals(new String[] { "1bob" }, store.get(0).getPermissions()); - // not in the permissions - assertFalse(tagHelper.removeTag("alice", value)); - // no permission is left - assertTrue(tagHelper.removeTag("bob", value)); - assertTrue(store.isEmpty()); - assertEquals(1, deleted.size()); - // unknown tag - assertFalse(tagHelper.removeTag("bob", value)); - } - - @Test - public void test_removeTag_keepsTagWithGroupOrRole() { - tagHelper.addTag("alice", "http://example.com/a", "提案書"); - final LabelType tag = store.get(0); - tag.setPermissions(new String[] { "1alice", "2dev" }); - - assertFalse(tagHelper.removeTag("alice", tag.getValue())); - Assertions.assertArrayEquals(new String[] { "2dev" }, tag.getPermissions()); - assertTrue(deleted.isEmpty()); - } - - @Test - public void test_isMine() { - final LabelTypeHelper.LabelTypeItem item = new LabelTypeHelper.LabelTypeItem(); - item.setPermissions(new String[] { "1alice", "Rguest" }); - assertTrue(tagHelper.isMine(item, "alice")); - assertFalse(tagHelper.isMine(item, "bob")); - assertFalse(tagHelper.isMine(item, null)); - } -} diff --git a/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java b/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java index c977916f56..6fd560d627 100644 --- a/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java @@ -119,11 +119,6 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } - @Override - public String getIndexFieldTag() { - return "tag"; - } - @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryCommandTest.java b/src/test/java/org/codelibs/fess/query/QueryCommandTest.java index 78d6c7a733..d01160e1ce 100644 --- a/src/test/java/org/codelibs/fess/query/QueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryCommandTest.java @@ -124,11 +124,6 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } - @Override - public String getIndexFieldTag() { - return "tag"; - } - @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java b/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java index 82dc1af8a8..17bc987ac2 100644 --- a/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java @@ -473,11 +473,6 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } - @Override - public String getIndexFieldTag() { - return "tag"; - } - @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java b/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java index 1b50d07478..c824444fa4 100644 --- a/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java @@ -172,11 +172,6 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } - @Override - public String getIndexFieldTag() { - return "tag"; - } - @Override public String getIndexFieldCreated() { return "created"; @@ -1066,23 +1061,6 @@ public void test_isApiResponseField_defaultAllowlist_allowsSafeFields() { "default API response allowlist size changed; actual set: " + config.apiResponseFieldSet); } - @Test - public void test_defaultConfig_tagFields() { - final QueryFieldConfig config = ComponentUtil.getQueryFieldConfig(); - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String tag = fessConfig.getIndexFieldTag(); - - // tag:, fields.tag= and facet.field=tag work - assertTrue(config.searchFieldSet.contains(tag)); - assertTrue(config.isFacetField(tag)); - assertTrue(config.notAnalyzedFieldSet.contains(tag)); - assertTrue(Arrays.asList(config.getResponseFields()).contains(tag)); - // the raw tag values include tags the caller cannot see: the search handler returns - // the visible ones as "tags", so the field itself is never an API response field - assertFalse(config.isApiResponseField(tag)); - assertFalse(Arrays.asList(config.getScrollResponseFields()).contains(tag)); - } - @Test public void test_defaultConfig_ownerAndLastModifierAreSearchableFields() { final QueryFieldConfig config = ComponentUtil.getQueryFieldConfig(); diff --git a/src/test/java/org/codelibs/fess/query/QueryTestBase.java b/src/test/java/org/codelibs/fess/query/QueryTestBase.java index 5472847891..d6b5c71b33 100644 --- a/src/test/java/org/codelibs/fess/query/QueryTestBase.java +++ b/src/test/java/org/codelibs/fess/query/QueryTestBase.java @@ -163,11 +163,6 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } - @Override - public String getIndexFieldTag() { - return "tag"; - } - @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java b/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java deleted file mode 100644 index da610a2d49..0000000000 --- a/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java +++ /dev/null @@ -1,217 +0,0 @@ -/* - * Copyright 2012-2025 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.query; - -import java.util.Locale; -import java.util.Set; - -import org.codelibs.fess.entity.QueryContext; -import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; -import org.codelibs.fess.helper.LabelTypeHelper; -import org.codelibs.fess.query.StructuredQuerySplitter.Split; -import org.codelibs.fess.util.ComponentUtil; -import org.codelibs.fesen.opensearch.index.query.BoolQueryBuilder; -import org.codelibs.fesen.opensearch.index.query.MatchNoneQueryBuilder; -import org.codelibs.fesen.opensearch.index.query.QueryBuilder; -import org.codelibs.fesen.opensearch.index.query.TermQueryBuilder; -import org.junit.jupiter.api.Test; - -/** - * A condition on the {@code tag} field matches only the tags that the caller can see. - */ -public class TagQueryRestrictionTest extends QueryTestBase { - - private static final String VISIBLE = "a1b2c3d4visible"; - - private static final String HIDDEN = "e5f6a7b8hidden"; - - private int resolveCount; - - private SearchRequestType resolvedType; - - @Override - protected void setUpChild() throws Exception { - new TermQueryCommand().register(); - new PhraseQueryCommand().register(); - new WildcardQueryCommand().register(); - new PrefixQueryCommand().register(); - new FuzzyQueryCommand().register(); - new TermRangeQueryCommand().register(); - new BooleanQueryCommand().register(); - new BoostQueryCommand().register(); - resolveCount = 0; - resolvedType = null; - ComponentUtil.register(new LabelTypeHelper() { - @Override - public Set getTagValueSet(final SearchRequestType searchRequestType, final Locale requestLocale) { - resolveCount++; - resolvedType = searchRequestType; - return Set.of(VISIBLE); - } - }, "labelTypeHelper"); - } - - private QueryBuilder build(final String query, final SearchRequestType searchRequestType) { - final QueryContext context = new QueryContext(query, false); - context.setSearchRequestType(searchRequestType); - return queryProcessor.execute(context, ComponentUtil.getQueryParser().parse(query), 1.0f); - } - - private QueryBuilder build(final String query) { - return build(query, SearchRequestType.JSON); - } - - private void assertTermQuery(final QueryBuilder builder, final String value) { - assertTrue(builder instanceof TermQueryBuilder, "expected a term query: " + builder); - final TermQueryBuilder termQuery = (TermQueryBuilder) builder; - assertEquals("tag", termQuery.fieldName()); - assertEquals(value, termQuery.value()); - } - - private void assertMatchNone(final QueryBuilder builder) { - assertTrue(builder instanceof MatchNoneQueryBuilder, "expected a query matching nothing: " + builder); - } - - @Test - public void test_fieldsTag_visible() { - // fields.tag= is turned into tag:"" - assertTermQuery(build("tag:\"" + VISIBLE + "\""), VISIBLE); - } - - @Test - public void test_fieldsTag_hidden() { - assertMatchNone(build("tag:\"" + HIDDEN + "\"")); - } - - @Test - public void test_queryTag_visible() { - assertTermQuery(build("tag:" + VISIBLE), VISIBLE); - } - - @Test - public void test_queryTag_hidden() { - assertMatchNone(build("tag:" + HIDDEN)); - } - - @Test - public void test_queryTag_hiddenWithKeyword() { - final QueryBuilder builder = build("report tag:" + HIDDEN); - assertTrue(builder instanceof BoolQueryBuilder, builder.toString()); - assertTrue(builder.toString().contains("match_none"), builder.toString()); - assertFalse(builder.toString().contains(HIDDEN), builder.toString()); - } - - @Test - public void test_queryTag_patterns() { - assertMatchNone(build("tag:e5f6*")); - assertMatchNone(build("tag:e5f6?7b8hidden")); - assertMatchNone(build("tag:" + HIDDEN + "~")); - assertMatchNone(build("tag:[a TO z]")); - assertMatchNone(build("tag:\"" + VISIBLE + " " + HIDDEN + "\"")); - } - - @Test - public void test_queryTag_negatedHidden() { - final QueryBuilder builder = build("-tag:" + HIDDEN); - assertTrue(builder instanceof BoolQueryBuilder, builder.toString()); - final BoolQueryBuilder boolQuery = (BoolQueryBuilder) builder; - assertEquals(1, boolQuery.mustNot().size()); - assertMatchNone(boolQuery.mustNot().get(0)); - } - - @Test - public void test_queryTag_resolvedOnce() { - build("tag:" + VISIBLE + " OR tag:" + HIDDEN + " OR tag:\"" + VISIBLE + "\""); - assertEquals(1, resolveCount); - assertEquals(SearchRequestType.JSON, resolvedType); - } - - @Test - public void test_queryTag_unknownRequestType() { - assertMatchNone(build("tag:" + HIDDEN, null)); - assertEquals(SearchRequestType.JSON, resolvedType); - } - - @Test - public void test_queryTag_adminSearch() { - assertTermQuery(build("tag:" + HIDDEN, SearchRequestType.ADMIN_SEARCH), HIDDEN); - assertEquals(0, resolveCount); - } - - /** - * The vector leg builds the conditions of the query with the same commands as the keyword leg, - * so a condition on the tag field has to see the request type of the search that is running. - */ - private QueryBuilder splitFilter(final String query, final SearchRequestType searchRequestType) { - final Split split = new StructuredQuerySplitter().split(query, searchRequestType); - assertNotNull(split); - assertEquals("report", split.text); - assertNotNull(split.conditionFilter); - return split.conditionFilter; - } - - @Test - public void test_vectorLeg_adminSearch_keepsTheTagOfAnyValue() { - // Like the keyword leg of an admin search, the vector leg is not limited to the visible tags. - final String filter = splitFilter("report tag:" + HIDDEN, SearchRequestType.ADMIN_SEARCH).toString(); - assertTrue(filter.contains(HIDDEN), filter); - assertFalse(filter.contains("match_none"), filter); - assertEquals(0, resolveCount); - } - - @Test - public void test_vectorLeg_jsonSearch_stillLimitedToVisibleTags() { - final String filter = splitFilter("report tag:" + HIDDEN, SearchRequestType.JSON).toString(); - assertTrue(filter.contains("match_none"), filter); - assertFalse(filter.contains(HIDDEN), filter); - assertEquals(SearchRequestType.JSON, resolvedType); - assertTrue(splitFilter("report tag:" + VISIBLE, SearchRequestType.JSON).toString().contains(VISIBLE)); - } - - @Test - public void test_vectorLeg_htmlSearch_limitedToTheTagsOfThatRequestType() { - final String filter = splitFilter("report tag:" + HIDDEN, SearchRequestType.SEARCH).toString(); - assertTrue(filter.contains("match_none"), filter); - assertFalse(filter.contains(HIDDEN), filter); - // the visible tags are resolved for the HTML search, not for the JSON API - assertEquals(SearchRequestType.SEARCH, resolvedType); - } - - @Test - public void test_vectorLeg_unknownRequestType_stillLimitedToVisibleTags() { - final String filter = splitFilter("report tag:" + HIDDEN, null).toString(); - assertTrue(filter.contains("match_none"), filter); - assertEquals(SearchRequestType.JSON, resolvedType); - } - - @Test - public void test_otherField_notResolved() { - final QueryBuilder builder = build("title:" + HIDDEN); - assertFalse(builder instanceof MatchNoneQueryBuilder, builder.toString()); - assertEquals(0, resolveCount); - } - - @Test - public void test_queryTag_resolutionFails() { - ComponentUtil.register(new LabelTypeHelper() { - @Override - public Set getTagValueSet(final SearchRequestType searchRequestType, final Locale requestLocale) { - throw new IllegalStateException("not available"); - } - }, "labelTypeHelper"); - assertMatchNone(build("tag:" + VISIBLE)); - } -} diff --git a/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java b/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java index 4a9124aef2..4d71838830 100644 --- a/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java @@ -198,11 +198,6 @@ public String getIndexFieldLastModifier() { return baseConfig.getIndexFieldLastModifier(); } - @Override - public String getIndexFieldTag() { - return baseConfig.getIndexFieldTag(); - } - @Override public String getIndexFieldCreated() { return baseConfig.getIndexFieldCreated(); diff --git a/src/test/js/themes/bootstrap/api.test.js b/src/test/js/themes/bootstrap/api.test.js index 47fd4e9b85..3e57c3ea3c 100644 --- a/src/test/js/themes/bootstrap/api.test.js +++ b/src/test/js/themes/bootstrap/api.test.js @@ -113,37 +113,6 @@ describe("post", () => { }); }); -describe("del", () => { - it("sends DELETE with the query string and the CSRF header, and unwraps the envelope", async () => { - api.setCsrfToken("tok-9"); - const fetchMock = installFetch(async () => envelope({ status: 0, removed: true })); - const env = await api.del("/documents/d1/tags", { value: "a1 b&c=d" }); - expect(env.removed).toBe(true); - const [url, opts] = fetchMock.mock.calls[0]; - expect(url).toBe("api/v2/documents/d1/tags?value=a1+b%26c%3Dd"); - expect(opts.method).toBe("DELETE"); - expect(opts.credentials).toBe("same-origin"); - expect(opts.headers["X-Fess-CSRF-Token"]).toBe("tok-9"); - expect(opts.body).toBeUndefined(); - }); - - it("omits the query string without parameters", async () => { - const fetchMock = installFetch(async () => envelope({ status: 0 })); - await api.del("/x"); - expect(fetchMock.mock.calls[0][0]).toBe("api/v2/x"); - }); - - it("throws ApiError from an error envelope", async () => { - installFetch(async () => jsonResponse({ response: { status: 1, error: { code: "invalid_request", message: "bad" } } }, { status: 400 })); - await expect(api.del("/x")).rejects.toMatchObject({ code: "invalid_request", message: "bad", httpStatus: 400 }); - }); - - it("wraps a fetch rejection in NetworkError", async () => { - installFetch(async () => { throw new Error("down"); }); - await expect(api.del("/x")).rejects.toBeInstanceOf(api.NetworkError); - }); -}); - describe("init + state accessors", () => { it("init stores the config envelope and csrf token", async () => { installFetch(async () => envelope({ status: 0, csrf_token: "abc", search: { enabled: true } })); diff --git a/src/test/js/themes/bootstrap/user-tags.test.js b/src/test/js/themes/bootstrap/user-tags.test.js deleted file mode 100644 index ab6539c5ca..0000000000 --- a/src/test/js/themes/bootstrap/user-tags.test.js +++ /dev/null @@ -1,522 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// Tests for the shared user tags of the bootstrap theme's search.js (features.user_tag): the -// tag chips and the inline tag editor on the result cards, the "tag" facet group, the -// fields.tag filter and its active-filter badge. A tag is a name; its value is an opaque id -// (64 hex characters) that the theme never parses. api.js and router.js are mocked so the -// config, the auth state and the /search and /documents/{id}/tags answers are controllable; -// i18n.js and format.js run for real, and i18n.t() returns its key unchanged (no messages -// loaded), so assertions match i18n keys. - -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; -import { resetDom, mountBody, setLocation } from "../../helpers/dom.js"; -import { jsonResponse, installFetch } from "../../helpers/net.js"; - -const API_PATH = "../../../../main/webapp/themes/bootstrap/assets/api.js"; - -vi.mock("../../../../main/webapp/themes/bootstrap/assets/api.js", () => ({ - getConfig: vi.fn(() => null), - get: vi.fn(async () => ({})), - post: vi.fn(async () => ({})), - del: vi.fn(async () => ({})), - isAuthenticated: vi.fn(() => false), - url: vi.fn((path) => "api/v2" + path), -})); -vi.mock("../../../../main/webapp/themes/bootstrap/assets/router.js", () => ({ - navigate: vi.fn(), -})); - -import * as api from "../../../../main/webapp/themes/bootstrap/assets/api.js"; -import { navigate } from "../../../../main/webapp/themes/bootstrap/assets/router.js"; -import { runSearch, runFromUrl, clearSearchState, attach, _state } from "../../../../main/webapp/themes/bootstrap/assets/search.js"; - -const FIXTURE = ` - - -
-
-
-
    -
    -
    -
    -
    -
    -
    -
    -`; - -// An opaque tag value: 64 hex characters, as the server issues them. -const hex = (n) => n.toString(16).padStart(64, "0"); -const V = { a: hex(0xa), b: hex(0xb), proposal: hex(0x1), draft: hex(0x2), old: hex(0x3), z: hex(0xf) }; - -function config({ userTag = true } = {}) { - return { features: { user_tag: userTag } }; -} - -const TAG = (value, name, mine = false) => ({ value, name, mine }); - -function searchEnv(docs, tagFacet) { - return { - query_id: "qid-1", - record_count: docs.length, - data: docs, - facet_field: [ - { name: "label", result: [] }, - ...(tagFacet ? [{ name: "tag", result: tagFacet }] : []), - ], - }; -} - -let env; -let tagsAnswer; -function installApi() { - api.get.mockImplementation(async (path) => { - if (path === "/search") return env; - if (path === "/labels") return { labels: [] }; - if (path.endsWith("/tags")) return tagsAnswer; - return {}; - }); -} - -const settle = () => new Promise((r) => setTimeout(r)); -const searchCalls = () => api.get.mock.calls.filter((c) => c[0] === "/search"); -const lastSearchParams = () => searchCalls().at(-1)[1]; -const tagGroup = () => [...document.querySelectorAll("#facet-body ul.list-group")] - .find((ul) => ul.firstChild.textContent === "tag.title"); -const badgeTexts = () => [...document.querySelectorAll("#active-chips .active-chip > span")].map((s) => s.textContent); - -async function search(docs, tagFacet) { - env = searchEnv(docs, tagFacet); - installApi(); - await runSearch(); - await settle(); -} - -async function searchFromUrl(url, docs, tagFacet) { - env = searchEnv(docs, tagFacet); - installApi(); - setLocation(url); - runFromUrl(); - await settle(); - await settle(); -} - -let modalShow; -beforeEach(() => { - resetDom(); - vi.clearAllMocks(); - api.getConfig.mockReturnValue(null); - clearSearchState(); - mountBody(FIXTURE); - setLocation("/search?q=foo"); - _state.q = "foo"; - api.getConfig.mockReturnValue(config()); - api.isAuthenticated.mockReturnValue(false); - tagsAnswer = { status: 0, doc_id: "d1", addable: true, tags: [] }; - modalShow = vi.fn(); - window.bootstrap = { Modal: { getOrCreateInstance: vi.fn(() => ({ show: modalShow })) } }; -}); -afterEach(() => { - delete window.bootstrap; - setLocation("/"); -}); - -describe("feature flag", () => { - it("renders no tags, no editor and no tag facet, and asks for the label facet only, when the feature is off", async () => { - api.getConfig.mockReturnValue(config({ userTag: false })); - api.isAuthenticated.mockReturnValue(true); - await search([{ doc_id: "d1", title: "T", tags: [TAG(V.a, "a")] }], [{ value: V.a, count: 2, label: "a" }]); - - expect(document.querySelector("#result0 .tags")).toBeNull(); - expect(document.querySelector(".tag-add-btn")).toBeNull(); - expect(lastSearchParams()["facet.field"]).toEqual(["label"]); - // Neither the dedicated group nor the generic one shows the tag field. - expect(tagGroup()).toBeUndefined(); - const titles = [...document.querySelectorAll("#facet-body ul.list-group > li:first-child")].map((li) => li.textContent); - expect(titles).not.toContain("tag"); - }); - - it("asks for the tag facet as well when the feature is on", async () => { - await search([{ doc_id: "d1", title: "T" }]); - expect(lastSearchParams()["facet.field"]).toEqual(["label", "tag"]); - }); - - it("renders no tag row for a guest when the document has no tags", async () => { - await search([{ doc_id: "d1", title: "T", tags: [] }]); - expect(document.querySelector("#result0 .tags")).toBeNull(); - }); - - it("offers the add control to a logged-in user even when the document has no tags", async () => { - api.isAuthenticated.mockReturnValue(true); - await search([{ doc_id: "d1", title: "T" }]); - expect(document.querySelector("#result0 .tag-chip")).toBeNull(); - expect(document.querySelector("#result0 .tag-add-btn")).not.toBeNull(); - }); -}); - -describe("result card chips", () => { - it("renders each tag as a chip with its name as plain text", async () => { - const evil = ''; - await search([{ doc_id: "d1", title: "T", tags: [TAG(V.proposal, "proposal", true), TAG(V.b, evil)] }]); - - const chips = [...document.querySelectorAll("#result0 .tags .tag-chip")]; - expect(chips.map((c) => c.textContent)).toEqual(["proposal", evil]); - expect(document.querySelector("#result0 .tags img")).toBeNull(); - expect(chips[0].tagName).toBe("BUTTON"); - expect(chips[0].getAttribute("type")).toBe("button"); - // A guest sees the tags but no add control. - expect(document.querySelector("#result0 .tag-add-btn")).toBeNull(); - }); - - it("shows a name containing a colon as is, and the raw value of a tag without a name", async () => { - await search([{ doc_id: "d1", title: "T", tags: [TAG(V.a, "x:y"), { value: V.b }] }]); - expect([...document.querySelectorAll("#result0 .tag-chip")].map((c) => c.textContent)).toEqual(["x:y", V.b]); - }); - - it("filters the search by the clicked tag's value, keeping the query, and puts fields.tag in the URL", async () => { - await search([{ doc_id: "d1", title: "T", tags: [TAG(V.a, "a b")] }]); - _state.fields.label = ["lblA"]; - document.querySelector("#result0 .tag-chip").click(); - await settle(); - - expect(searchCalls().length).toBe(2); - const params = lastSearchParams(); - expect(params.q).toBe("foo"); - expect(params["fields.tag"]).toEqual([V.a]); - expect(params["fields.label"]).toEqual(["lblA"]); - expect(params.start).toBe(0); - const qs = new URLSearchParams(location.search); - expect(qs.getAll("fields.tag")).toEqual([V.a]); - expect(qs.get("q")).toBe("foo"); - }); -}); - -describe("tag facet", () => { - const FACET = [ - { value: V.proposal, count: 3, label: "proposal" }, - { value: V.draft, count: 1, label: "draft" }, - { value: V.old, count: 0, label: "old" }, - ]; - - it("renders one Tags group with the tag labels and counts, without zero counts", async () => { - await search([{ doc_id: "d1", title: "T" }], FACET); - - const group = tagGroup(); - expect(group).toBeTruthy(); - const items = [...group.querySelectorAll("li.list-group-item a")]; - expect(items.map((a) => a.firstChild.textContent.trim())).toEqual(["proposal", "draft"]); - expect(items.map((a) => a.querySelector(".badge").textContent)).toEqual(["3", "1"]); - // Not rendered a second time by the generic field facets. - const titles = [...document.querySelectorAll("#facet-body ul.list-group > li:first-child")].map((li) => li.textContent); - expect(titles.filter((x) => x === "tag.title" || x === "tag").length).toBe(1); - // Mirrored into the mobile offcanvas. - expect(document.querySelectorAll("#facet-body-mobile ul.list-group").length).toBe(1); - }); - - it("falls back to the raw value when an entry has no label, and renders a label as plain text", async () => { - const evil = "x"; - await search([{ doc_id: "d1", title: "T" }], [{ value: V.a, count: 2 }, { value: V.b, count: 1, label: evil }]); - const items = [...tagGroup().querySelectorAll("li.list-group-item a")]; - expect(items.map((a) => a.firstChild.textContent.trim())).toEqual([V.a, evil]); - expect(tagGroup().querySelector("b")).toBeNull(); - }); - - it("filters through fields.tag on click and clears the filter on a second click", async () => { - await search([{ doc_id: "d1", title: "T" }], FACET); - tagGroup().querySelector("li.list-group-item a").click(); - await settle(); - - expect(lastSearchParams()["fields.tag"]).toEqual([V.proposal]); - expect(lastSearchParams()["ex_q"]).toBeUndefined(); - expect(new URLSearchParams(location.search).getAll("fields.tag")).toEqual([V.proposal]); - const active = tagGroup().querySelector("li.list-group-item.active a"); - expect(active.firstChild.textContent.trim()).toBe("proposal"); - // The badge names the tag. - expect(badgeTexts()).toEqual(["proposal"]); - - active.click(); - await settle(); - expect(lastSearchParams()["fields.tag"]).toBeUndefined(); - expect(new URLSearchParams(location.search).has("fields.tag")).toBe(false); - }); - - it("keeps a selected tag listed even when its count is zero", async () => { - _state.fields.tag = [V.old]; - await search([{ doc_id: "d1", title: "T" }], FACET); - const active = tagGroup().querySelector("li.list-group-item.active a"); - expect(active.firstChild.textContent.trim()).toBe("old"); - }); -}); - -describe("fields.tag filter badge", () => { - it("reads the tag from the URL and names it from the facet labels and the hits' tags, else shows the raw value", async () => { - const gone = hex(0xdead); - await searchFromUrl( - "/search?q=foo&fields.tag=" + V.proposal + "&fields.tag=" + V.a + "&fields.tag=" + gone, - [{ doc_id: "d1", title: "T", tags: [TAG(V.a, "alpha")] }], - [{ value: V.proposal, count: 1, label: "proposal" }], - ); - - expect(lastSearchParams()["fields.tag"]).toEqual([V.proposal, V.a, gone]); - expect(badgeTexts()).toEqual(["proposal", "alpha", gone]); - }); - - it("names the tag from the current response only", async () => { - await searchFromUrl("/search?q=foo&fields.tag=" + V.a, [{ doc_id: "d1", title: "T", tags: [TAG(V.a, "alpha")] }]); - expect(badgeTexts()).toEqual(["alpha"]); - await search([{ doc_id: "d2", title: "U" }]); - expect(badgeTexts()).toEqual([V.a]); - }); - - it("drops the tag from the request and the URL when its badge is removed", async () => { - await searchFromUrl("/search?q=foo&fields.tag=" + V.proposal, [{ doc_id: "d1", title: "T" }]); - document.querySelector("#active-chips .active-chip-remove").click(); - await settle(); - - expect(lastSearchParams()["fields.tag"]).toBeUndefined(); - expect(new URLSearchParams(location.search).has("fields.tag")).toBe(false); - expect(new URLSearchParams(location.search).get("q")).toBe("foo"); - }); - - it("is kept by the search-options Search button", () => { - setLocation("/search?q=foo&fields.tag=" + V.proposal + "&ex_q=label%3Ax"); - attach(); - document.getElementById("query").value = "foo"; - document.querySelector("#searchOptions button[type=submit]").click(); - - const target = navigate.mock.calls.at(-1)[0]; - const qs = new URLSearchParams(target.slice(target.indexOf("?") + 1)); - expect(qs.getAll("fields.tag")).toEqual([V.proposal]); - expect(qs.getAll("ex_q")).toEqual(["label:x"]); - }); -}); - -describe("tag editor", () => { - const card = () => document.getElementById("result0"); - const toggle = () => card().querySelector(".tag-add-btn"); - const editor = () => card().querySelector(".tag-editor"); - const editorItems = () => [...editor().querySelectorAll(".tag-editor-item")]; - const input = () => editor().querySelector("input"); - const errorBox = () => editor().querySelector(".tag-editor-error"); - const chipTexts = () => [...card().querySelectorAll(".tag-chips .tag-chip")].map((c) => c.textContent); - - async function openEditor(docs = [{ doc_id: "d1", title: "T", tags: [TAG(V.a, "a", true)] }]) { - api.isAuthenticated.mockReturnValue(true); - await search(docs); - toggle().click(); - await settle(); - } - - async function add(name) { - input().value = name; - editor().querySelector("button[type=submit]").click(); - await settle(); - } - - it("shows an add control to a logged-in user that opens the editor and loads the document's tags", async () => { - tagsAnswer = { - status: 0, doc_id: "d1", addable: true, - tags: [TAG(V.a, "a", true), TAG(V.b, "b", false)], - }; - await openEditor(); - - expect(toggle().getAttribute("aria-expanded")).toBe("true"); - expect(toggle().getAttribute("aria-controls")).toBe(editor().id); - expect(toggle().textContent).toBe("tag.add"); - expect(api.get).toHaveBeenCalledWith("/documents/d1/tags"); - expect(editor().getAttribute("aria-label")).toBe("tag.title"); - const items = editorItems(); - expect(items.map((li) => li.firstChild.textContent)).toEqual(["a", "b"]); - // No counts any more. - expect(editor().querySelector(".tag-count")).toBeNull(); - expect(items.map((li) => li.textContent)).toEqual(["a", "b"]); - // Only the user's own tag can be removed from their tags. - expect(items[0].querySelector("button.tag-remove").getAttribute("aria-label")).toBe("tag.remove"); - expect(items[1].querySelector("button.tag-remove")).toBeNull(); - // The input is labelled, capped at 50 characters and takes the focus. - expect(input().getAttribute("maxlength")).toBe("50"); - expect(editor().querySelector(`label[for="${input().id}"]`).textContent).toBe("tag.name"); - expect(document.activeElement).toBe(input()); - expect(editor().querySelector(".tag-editor-empty").classList.contains("d-none")).toBe(true); - // The chips follow the answer. - expect(chipTexts()).toEqual(["a", "b"]); - }); - - it("offers a name input and no type select", async () => { - await openEditor(); - expect(editor().querySelector("select")).toBeNull(); - expect(editor().querySelectorAll("input").length).toBe(1); - }); - - it("adds a tag by name and updates the chips without a new search", async () => { - await openEditor(); - api.post.mockResolvedValue({ - status: 0, doc_id: "d1", addable: true, added: true, - tags: [TAG(V.a, "a", true), TAG(V.b, "new one", true)], - }); - const before = searchCalls().length; - await add(" new one "); - - expect(api.post).toHaveBeenCalledWith("/documents/d1/tags", { name: "new one" }); - expect(chipTexts()).toEqual(["a", "new one"]); - expect(editorItems().length).toBe(2); - expect(input().value).toBe(""); - expect(document.activeElement).toBe(input()); - expect(searchCalls().length).toBe(before); - }); - - it("ignores an empty name", async () => { - await openEditor(); - await add(" "); - expect(api.post).not.toHaveBeenCalled(); - await add("x"); - expect(api.post).toHaveBeenCalledWith("/documents/d1/tags", { name: "x" }); - }); - - it("opens the login modal when adding answers 401 or 403", async () => { - await openEditor(); - api.post.mockRejectedValueOnce({ code: "auth_required", httpStatus: 401, message: "login" }); - await add("x"); - expect(window.bootstrap.Modal.getOrCreateInstance).toHaveBeenCalledWith(document.getElementById("login-modal")); - expect(modalShow).toHaveBeenCalledTimes(1); - expect(errorBox().classList.contains("d-none")).toBe(true); - - api.post.mockRejectedValueOnce({ code: "forbidden", httpStatus: 403, message: "csrf" }); - await add("x"); - expect(modalShow).toHaveBeenCalledTimes(2); - }); - - it("shows the server's message for a 400 and a generic one for other failures", async () => { - await openEditor(); - api.post.mockRejectedValueOnce({ code: "invalid_request", httpStatus: 400, message: "Too many tags." }); - await add("x"); - expect(errorBox().textContent).toBe("Too many tags."); - expect(errorBox().classList.contains("d-none")).toBe(false); - expect(errorBox().getAttribute("aria-live")).toBe("polite"); - expect(input().value).toBe("x"); - - api.post.mockRejectedValueOnce({ name: "NetworkError", message: "down" }); - await add("x"); - expect(errorBox().textContent).toBe("tag.error"); - expect(modalShow).not.toHaveBeenCalled(); - - // The next successful answer clears the message. - api.post.mockResolvedValueOnce({ status: 0, addable: true, added: true, tags: [] }); - await add("x"); - expect(errorBox().classList.contains("d-none")).toBe(true); - }); - - it("removes a tag from the user's tags through DELETE with its value and updates the chips", async () => { - tagsAnswer = { status: 0, doc_id: "d/1", addable: true, tags: [TAG(V.a, "a b&c", true)] }; - api.del.mockResolvedValue({ status: 0, doc_id: "d/1", addable: true, removed: true, tags: [] }); - await openEditor([{ doc_id: "d/1", title: "T", tags: [TAG(V.a, "a b&c", true)] }]); - expect(api.get).toHaveBeenCalledWith("/documents/d%2F1/tags"); - - editorItems()[0].querySelector("button.tag-remove").click(); - await settle(); - - expect(api.del).toHaveBeenCalledWith("/documents/d%2F1/tags", { value: V.a }); - expect(chipTexts()).toEqual([]); - expect(editorItems().length).toBe(0); - expect(editor().querySelector(".tag-editor-empty").classList.contains("d-none")).toBe(false); - expect(document.activeElement).toBe(input()); - }); - - it("keeps a removed tag the user still sees through a group, without the remove button", async () => { - tagsAnswer = { status: 0, doc_id: "d1", addable: true, tags: [TAG(V.a, "a", true)] }; - api.del.mockResolvedValue({ status: 0, doc_id: "d1", addable: true, removed: true, tags: [TAG(V.a, "a", false)] }); - await openEditor(); - editorItems()[0].querySelector("button.tag-remove").click(); - await settle(); - - expect(chipTexts()).toEqual(["a"]); - expect(editorItems().map((li) => li.textContent)).toEqual(["a"]); - expect(editorItems()[0].querySelector("button.tag-remove")).toBeNull(); - }); - - it("URL-encodes the tag value in the DELETE request", async () => { - const actual = await vi.importActual(API_PATH); - const fetchMock = installFetch(async () => jsonResponse({ response: { status: 0, addable: true, removed: true, tags: [] } })); - api.del.mockImplementation(actual.del); - // An unexpected value must still reach the server intact. - const odd = "a b&c=d"; - tagsAnswer = { status: 0, addable: true, tags: [TAG(odd, "odd", true)] }; - try { - await openEditor(); - editorItems()[0].querySelector("button.tag-remove").click(); - await settle(); - const [url, opts] = fetchMock.mock.calls[0]; - expect(url).toBe("api/v2/documents/d1/tags?value=a+b%26c%3Dd"); - expect(opts.method).toBe("DELETE"); - expect(new URLSearchParams(url.split("?")[1]).get("value")).toBe(odd); - } finally { - vi.unstubAllGlobals(); - } - }); - - it("re-enables the remove button and asks for login when the DELETE answers 401", async () => { - tagsAnswer = { status: 0, addable: true, tags: [TAG(V.a, "a", true)] }; - api.del.mockRejectedValueOnce({ code: "auth_required", httpStatus: 401 }); - await openEditor(); - const btn = editorItems()[0].querySelector("button.tag-remove"); - btn.click(); - await settle(); - expect(modalShow).toHaveBeenCalledTimes(1); - expect(btn.disabled).toBe(false); - }); - - it("shows the server's message inline when the DELETE answers 400", async () => { - tagsAnswer = { status: 0, addable: true, tags: [TAG(V.a, "a", true)] }; - api.del.mockRejectedValueOnce({ code: "invalid_request", httpStatus: 400, message: "Not your tag." }); - await openEditor(); - editorItems()[0].querySelector("button.tag-remove").click(); - await settle(); - expect(errorBox().textContent).toBe("Not your tag."); - expect(modalShow).not.toHaveBeenCalled(); - }); - - it("hides the add form when the server says the user cannot add tags", async () => { - tagsAnswer = { status: 0, addable: false, tags: [TAG(V.a, "a", false)] }; - await openEditor(); - expect(editor().querySelector("form").classList.contains("d-none")).toBe(true); - }); - - it("shows the generic error when the tags cannot be loaded", async () => { - api.isAuthenticated.mockReturnValue(true); - await search([{ doc_id: "d1", title: "T" }]); - api.get.mockImplementation(async (path) => { - if (path.endsWith("/tags")) throw { code: "server_error", httpStatus: 500, message: "boom" }; - return {}; - }); - toggle().click(); - await settle(); - expect(errorBox().textContent).toBe("tag.error"); - }); - - it("closes on Escape and on a second toggle click, returning the focus to the toggle", async () => { - await openEditor(); - input().dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); - expect(editor().classList.contains("d-none")).toBe(true); - expect(toggle().getAttribute("aria-expanded")).toBe("false"); - expect(document.activeElement).toBe(toggle()); - - // Reopening reuses the editor and reloads the tags. - toggle().click(); - await settle(); - expect(card().querySelectorAll(".tag-editor").length).toBe(1); - expect(editor().classList.contains("d-none")).toBe(false); - expect(api.get.mock.calls.filter((c) => c[0] === "/documents/d1/tags").length).toBe(2); - - toggle().click(); - expect(editor().classList.contains("d-none")).toBe(true); - expect(toggle().getAttribute("aria-expanded")).toBe("false"); - }); - - it("filters by a chip after the chips were re-rendered from the editor", async () => { - tagsAnswer = { status: 0, addable: true, tags: [TAG(V.z, "zeta", true)] }; - await openEditor(); - card().querySelector(".tag-chips .tag-chip").click(); - await settle(); - expect(lastSearchParams()["fields.tag"]).toEqual([V.z]); - }); -}); From 5628c60b94ff2b567d05a61d80bd2877b3f186e0 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:07:30 +0900 Subject: [PATCH 02/14] feat(tagtype): add the tag_type config index and the tag document field Add the fess_config.tag_type index for the tags each logged-in user owns: name, owner, paths, permissions, virtualHost, sortOrder and the usual created/updated fields, with the ESFlute classes generated by FreeGen. TagTypeService lists, reads, creates, updates and deletes tag types. A create uses op_type=create and an update uses if_seq_no/if_primary_term, and a lost race on either is reported as TagTypeConflictException. Listings leave paths out of _source, as a tag can list many URLs. Add the tag keyword field to the document mapping. An existing document index gets only that field on webapp startup, and a tag field that is already mapped is left as it is, with a warning when it is not keyword. Add the user.tag.* settings, index.field.tag, page.tagtype.max.fetch.size and online.help.name.tagtype, and leave tag out of the index export. --- dbflute_fess/dfprop/esfluteMap.dfprop | 5 + src/main/config/es/fess_config_tag_type.json | 65 + .../codelibs/fess/app/pager/TagTypePager.java | 256 ++ .../fess/app/service/TagTypeService.java | 245 ++ .../exception/TagTypeConflictException.java | 35 + .../fess/mylasta/direction/FessConfig.java | 269 +- .../opensearch/client/SearchEngineClient.java | 43 +- .../opensearch/config/bsbhv/BsTagTypeBhv.java | 266 ++ .../opensearch/config/bsentity/BsTagType.java | 253 ++ .../config/bsentity/dbmeta/TagTypeDbm.java | 282 ++ .../opensearch/config/cbean/TagTypeCB.java | 24 + .../config/cbean/bs/BsTagTypeCB.java | 219 ++ .../opensearch/config/cbean/ca/TagTypeCA.java | 24 + .../config/cbean/ca/bs/BsTagTypeCA.java | 1701 ++++++++++++ .../opensearch/config/cbean/cq/TagTypeCQ.java | 24 + .../config/cbean/cq/bs/BsTagTypeCQ.java | 2297 +++++++++++++++++ .../opensearch/config/exbhv/TagTypeBhv.java | 37 + .../opensearch/config/exentity/TagType.java | 87 + src/main/resources/esclient.xml | 3 + src/main/resources/esflute_config.xml | 1 + src/main/resources/fess_config.properties | 22 +- .../resources/fess_indices/_aws/fess/doc.json | 3 + .../fess_indices/_cloud/fess/doc.json | 3 + src/main/resources/fess_indices/fess/doc.json | 3 + .../fess_indices/fess_config.tag_type.json | 10 + .../alias/fess_config.json | 1 + .../fess_config.tag_type/tag_type.json | 34 + .../fess/app/service/TagTypeServiceTest.java | 191 ++ .../SearchEngineClientIndexSettingTest.java | 103 +- 29 files changed, 6497 insertions(+), 9 deletions(-) create mode 100644 src/main/config/es/fess_config_tag_type.json create mode 100644 src/main/java/org/codelibs/fess/app/pager/TagTypePager.java create mode 100644 src/main/java/org/codelibs/fess/app/service/TagTypeService.java create mode 100644 src/main/java/org/codelibs/fess/exception/TagTypeConflictException.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsTagTypeBhv.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsTagType.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/TagTypeDbm.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/TagTypeCB.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsTagTypeCB.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/TagTypeCA.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsTagTypeCA.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/TagTypeCQ.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsTagTypeCQ.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/exbhv/TagTypeBhv.java create mode 100644 src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java create mode 100644 src/main/resources/fess_indices/fess_config.tag_type.json create mode 100644 src/main/resources/fess_indices/fess_config.tag_type/alias/fess_config.json create mode 100644 src/main/resources/fess_indices/fess_config.tag_type/tag_type.json create mode 100644 src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java diff --git a/dbflute_fess/dfprop/esfluteMap.dfprop b/dbflute_fess/dfprop/esfluteMap.dfprop index d1287c8017..f7d3ab560b 100644 --- a/dbflute_fess/dfprop/esfluteMap.dfprop +++ b/dbflute_fess/dfprop/esfluteMap.dfprop @@ -129,6 +129,11 @@ map:{ ; esclientDiFile = esclient.xml ; esfluteDiFile = esflute_config.xml } + ; fess_config.tag_type = map:{ + ; package = config + ; esclientDiFile = esclient.xml + ; esfluteDiFile = esflute_config.xml + } ; fess_config.thumbnail_queue = map:{ ; package = config ; esclientDiFile = esclient.xml diff --git a/src/main/config/es/fess_config_tag_type.json b/src/main/config/es/fess_config_tag_type.json new file mode 100644 index 0000000000..19c144b0ca --- /dev/null +++ b/src/main/config/es/fess_config_tag_type.json @@ -0,0 +1,65 @@ +{ + "fess_config.tag_type" : { + "aliases" : { }, + "mappings" : { + "tag_type" : { + "properties" : { + "createdBy" : { + "type" : "keyword" + }, + "createdTime" : { + "type" : "long" + }, + "name" : { + "type" : "keyword" + }, + "owner" : { + "type" : "keyword" + }, + "paths" : { + "type" : "keyword" + }, + "permissions" : { + "type" : "keyword" + }, + "sortOrder" : { + "type" : "integer" + }, + "updatedBy" : { + "type" : "keyword" + }, + "updatedTime" : { + "type" : "long" + }, + "virtualHost" : { + "type" : "keyword" + } + } + } + }, + "settings" : { + "index" : { + "dbflute" : { + "tag_type" : { + "properties" : { + "paths" : { + "array" : "true" + }, + "permissions" : { + "array" : "true" + } + } + } + }, + "creation_date" : "1509021055215", + "number_of_shards" : "5", + "number_of_replicas" : "1", + "uuid" : "Zylt7BsVRhiOasQfUWBg0w", + "version" : { + "created" : "6000051" + }, + "provided_name" : "fess_config.tag_type" + } + } + } +} diff --git a/src/main/java/org/codelibs/fess/app/pager/TagTypePager.java b/src/main/java/org/codelibs/fess/app/pager/TagTypePager.java new file mode 100644 index 0000000000..51f229c50e --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/pager/TagTypePager.java @@ -0,0 +1,256 @@ +/* + * Copyright 2012-2025 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.app.pager; + +import java.io.Serializable; +import java.util.List; + +import org.codelibs.fess.util.ComponentUtil; + +/** + * Pager for tag types. + */ +public class TagTypePager implements Serializable { + + private static final long serialVersionUID = 1L; + + /** + * Default constructor. + */ + public TagTypePager() { + // do nothing + } + + /** The default page size. */ + public static final int DEFAULT_PAGE_SIZE = 20; + + /** The default current page number. */ + public static final int DEFAULT_CURRENT_PAGE_NUMBER = 1; + + /** The total number of records. */ + private int allRecordCount; + + /** The total number of pages. */ + private int allPageCount; + + /** Indicates if a previous page exists. */ + private boolean existPrePage; + + /** Indicates if a next page exists. */ + private boolean existNextPage; + + /** The list of page numbers. */ + private List pageNumberList; + + /** The size of a page. */ + private int pageSize; + + /** The current page number. */ + private int currentPageNumber; + + /** The ID of the tag type. */ + public String id; + + /** The name of the tag type. */ + public String name; + + /** The owner of the tag type. */ + public String owner; + + /** The sort order of the tag type. */ + public String sortOrder; + + /** The creator of the tag type. */ + public String createdBy; + + /** The created time of the tag type. */ + public String createdTime; + + /** The version number of the tag type. */ + public String versionNo; + + /** + * Clears the pager fields. + */ + public void clear() { + allRecordCount = 0; + allPageCount = 0; + existPrePage = false; + existNextPage = false; + pageSize = getDefaultPageSize(); + currentPageNumber = getDefaultCurrentPageNumber(); + + id = null; + name = null; + owner = null; + sortOrder = null; + createdBy = null; + createdTime = null; + versionNo = null; + + } + + /** + * Returns the default current page number. + * + * @return The default current page number. + */ + protected int getDefaultCurrentPageNumber() { + return DEFAULT_CURRENT_PAGE_NUMBER; + } + + /** + * Returns the total number of records. + * + * @return The total number of records. + */ + public int getAllRecordCount() { + return allRecordCount; + } + + /** + * Sets the total number of records. + * + * @param allRecordCount The total number of records. + */ + public void setAllRecordCount(final int allRecordCount) { + this.allRecordCount = allRecordCount; + } + + /** + * Returns the total number of pages. + * + * @return The total number of pages. + */ + public int getAllPageCount() { + return allPageCount; + } + + /** + * Sets the total number of pages. + * + * @param allPageCount The total number of pages. + */ + public void setAllPageCount(final int allPageCount) { + this.allPageCount = allPageCount; + } + + /** + * Returns true if a previous page exists. + * + * @return True if a previous page exists. + */ + public boolean isExistPrePage() { + return existPrePage; + } + + /** + * Sets whether a previous page exists. + * + * @param existPrePage True if a previous page exists. + */ + public void setExistPrePage(final boolean existPrePage) { + this.existPrePage = existPrePage; + } + + /** + * Returns true if a next page exists. + * + * @return True if a next page exists. + */ + public boolean isExistNextPage() { + return existNextPage; + } + + /** + * Sets whether a next page exists. + * + * @param existNextPage True if a next page exists. + */ + public void setExistNextPage(final boolean existNextPage) { + this.existNextPage = existNextPage; + } + + /** + * Returns the page size. + * + * @return The page size. + */ + public int getPageSize() { + if (pageSize <= 0) { + pageSize = getDefaultPageSize(); + } + return pageSize; + } + + /** + * Sets the page size. + * + * @param pageSize The page size. + */ + public void setPageSize(final int pageSize) { + this.pageSize = pageSize; + } + + /** + * Returns the current page number. + * + * @return The current page number. + */ + public int getCurrentPageNumber() { + if (currentPageNumber <= 0) { + currentPageNumber = getDefaultCurrentPageNumber(); + } + return currentPageNumber; + } + + /** + * Sets the current page number. + * + * @param currentPageNumber The current page number. + */ + public void setCurrentPageNumber(final int currentPageNumber) { + this.currentPageNumber = currentPageNumber; + } + + /** + * Returns the list of page numbers. + * + * @return The list of page numbers. + */ + public List getPageNumberList() { + return pageNumberList; + } + + /** + * Sets the list of page numbers. + * + * @param pageNumberList The list of page numbers. + */ + public void setPageNumberList(final List pageNumberList) { + this.pageNumberList = pageNumberList; + } + + /** + * Returns the default page size. + * + * @return The default page size. + */ + protected int getDefaultPageSize() { + return ComponentUtil.getFessConfig().getPagingPageSizeAsInteger(); + } + +} diff --git a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java new file mode 100644 index 0000000000..ad84daf570 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java @@ -0,0 +1,245 @@ +/* + * Copyright 2012-2025 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.app.service; + +import java.util.List; + +import org.codelibs.core.beans.util.BeanUtil; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.fess.Constants; +import org.codelibs.fess.app.pager.TagTypePager; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; +import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.dbflute.cbean.result.PagingResultBean; +import org.dbflute.optional.OptionalEntity; + +import jakarta.annotation.Resource; + +/** + * Service class for tag types, the tags each logged-in user owns. + * + *

    The id of a tag type is derived from its name and owner, so a create must not overwrite a + * tag type that already exists, and an update must not overwrite a change made since the tag + * type was read. Both are enforced by the search engine: {@link #insert(TagType)} creates the + * document only when its id is free, and {@link #update(TagType)} writes it only when its + * sequence number and primary term are still the ones read. A lost race on either is reported as + * a {@link TagTypeConflictException}.

    + */ +public class TagTypeService extends FessAppService { + + /** The TagType behavior. */ + @Resource + protected TagTypeBhv tagTypeBhv; + + /** The Fess config. */ + @Resource + protected FessConfig fessConfig; + + /** + * Default constructor. + */ + public TagTypeService() { + } + + /** + * Get a page of tag types. + * + * @param tagTypePager The pager for tag types. + * @return A list of tag types. + */ + public List getTagTypeList(final TagTypePager tagTypePager) { + + final PagingResultBean tagTypeList = tagTypeBhv.selectPage(cb -> { + cb.paging(tagTypePager.getPageSize(), tagTypePager.getCurrentPageNumber()); + setupListCondition(cb, tagTypePager); + }); + + // update pager + BeanUtil.copyBeanToBean(tagTypeList, tagTypePager, option -> option.include(Constants.PAGER_CONVERSION_RULE)); + tagTypePager.setPageNumberList(tagTypeList.pageRange(op -> { + op.rangeSize(fessConfig.getPagingPageRangeSizeAsInteger()); + }).createPageNumberList()); + + return tagTypeList; + } + + /** + * Set up list conditions. + * + * @param cb The condition bean. + * @param tagTypePager The pager for tag types. + */ + protected void setupListCondition(final TagTypeCB cb, final TagTypePager tagTypePager) { + if (StringUtil.isNotBlank(tagTypePager.name)) { + cb.query().setName_Wildcard(wrapQuery(tagTypePager.name)); + } + if (StringUtil.isNotBlank(tagTypePager.owner)) { + cb.query().setOwner_Wildcard(wrapQuery(tagTypePager.owner)); + } + specifyColumnsWithoutPaths(cb); + + // setup condition + cb.query().addOrderBy_SortOrder_Asc(); + cb.query().addOrderBy_Name_Asc(); + cb.query().addOrderBy_Owner_Asc(); + } + + /** + * Get a tag type with all of its fields. + * + * @param id The ID of the tag type. + * @return An optional entity of the tag type. + */ + public OptionalEntity getTagType(final String id) { + return tagTypeBhv.selectByPK(id); + } + + /** + * Get the tag types of one owner, sorted by sort order and name. + * + *

    The returned entities do not carry their {@code paths}, which can hold + * {@code user.tag.max.paths} URLs each. Read a tag type with {@link #getTagType(String)} + * before passing it to {@link #update(TagType)}, or its paths are cleared.

    + * + * @param owner The owner of the tag types. + * @return A list of tag types without their paths. + */ + public List getTagTypeListByOwner(final String owner) { + return tagTypeBhv.selectList(cb -> { + cb.query().setOwner_Term(owner); + specifyColumnsWithoutPaths(cb); + cb.query().addOrderBy_SortOrder_Asc(); + cb.query().addOrderBy_Name_Asc(); + cb.paging(fessConfig.getUserTagMaxTagsAsInteger(), 1); + }); + } + + /** + * Count the tag types of one owner. + * + * @param owner The owner of the tag types. + * @return The number of tag types the owner has. + */ + public long countByOwner(final String owner) { + return tagTypeBhv.selectCount(cb -> cb.query().setOwner_Term(owner)); + } + + /** + * Create a tag type. Its id must be set; the write fails when a tag type with that id exists. + * + * @param tagType The tag type to create. + * @throws TagTypeConflictException When a tag type with the same id already exists. + */ + public void insert(final TagType tagType) { + try { + tagTypeBhv.insert(tagType, op -> { + op.setCreate(true); + op.setRefreshPolicy(Constants.TRUE); + }); + } catch (final RuntimeException e) { + if (isVersionConflict(e)) { + throw new TagTypeConflictException("The tag type already exists: id=" + tagType.getId(), e); + } + throw e; + } + } + + /** + * Update a tag type that was read with {@link #getTagType(String)}. The write fails when the + * stored tag type changed since it was read. + * + * @param tagType The tag type to update, carrying the sequence number and primary term it was read with. + * @throws IllegalArgumentException When the tag type carries no sequence number or primary term. + * @throws TagTypeConflictException When the stored tag type changed since it was read. + */ + public void update(final TagType tagType) { + final Long seqNo = tagType.getSeqNo(); + final Long primaryTerm = tagType.getPrimaryTerm(); + if (seqNo == null || seqNo.longValue() < 0L || primaryTerm == null || primaryTerm.longValue() <= 0L) { + throw new IllegalArgumentException("The tag type has no sequence number or primary term: id=" + tagType.getId()); + } + try { + tagTypeBhv.update(tagType, op -> { + op.setIfSeqNo(seqNo); + op.setIfPrimaryTerm(primaryTerm); + op.setRefreshPolicy(Constants.TRUE); + }); + } catch (final RuntimeException e) { + if (isVersionConflict(e)) { + throw new TagTypeConflictException("The tag type was changed concurrently: id=" + tagType.getId(), e); + } + throw e; + } + } + + /** + * Delete a tag type. + * + * @param tagType The tag type to delete. + */ + public void delete(final TagType tagType) { + tagTypeBhv.delete(tagType, op -> { + op.setRefreshPolicy(Constants.TRUE); + }); + } + + /** + * Fetches every field except {@code paths}. + * + * @param cb The condition bean. + */ + protected void specifyColumnsWithoutPaths(final TagTypeCB cb) { + cb.specify().columnName(); + cb.specify().columnOwner(); + cb.specify().columnPermissions(); + cb.specify().columnVirtualHost(); + cb.specify().columnSortOrder(); + cb.specify().columnCreatedBy(); + cb.specify().columnCreatedTime(); + cb.specify().columnUpdatedBy(); + cb.specify().columnUpdatedTime(); + } + + /** + * Tests whether the throwable, or one of its causes, is a version conflict of the search + * engine. The in-process client throws a {@code VersionConflictEngineException}; the HTTP + * client throws a generic exception whose message names the error type. + * + * @param t The throwable to inspect. + * @return true if it is a version conflict. + */ + protected boolean isVersionConflict(final Throwable t) { + Throwable cur = t; + while (cur != null) { + if (cur.getClass().getName().endsWith("VersionConflictEngineException")) { + return true; + } + final String msg = cur.getMessage(); + if (msg != null && msg.contains("type=version_conflict_engine_exception")) { + return true; + } + final Throwable next = cur.getCause(); + if (next == cur) { + break; + } + cur = next; + } + return false; + } +} diff --git a/src/main/java/org/codelibs/fess/exception/TagTypeConflictException.java b/src/main/java/org/codelibs/fess/exception/TagTypeConflictException.java new file mode 100644 index 0000000000..5abaf41709 --- /dev/null +++ b/src/main/java/org/codelibs/fess/exception/TagTypeConflictException.java @@ -0,0 +1,35 @@ +/* + * Copyright 2012-2025 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.exception; + +/** + * Thrown when a tag type cannot be written because another write got there first: a tag type + * with the same id already exists, or the stored one changed since it was read. + */ +public class TagTypeConflictException extends FessSystemException { + + private static final long serialVersionUID = 1L; + + /** + * Constructs a new exception with the specified detail message and cause. + * + * @param message The detail message. + * @param cause The version conflict reported by the search engine. + */ + public TagTypeConflictException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java b/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java index 0577a927db..40140a75bd 100644 --- a/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java +++ b/src/main/java/org/codelibs/fess/mylasta/direction/FessConfig.java @@ -717,6 +717,9 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. label */ String INDEX_FIELD_LABEL = "index.field.label"; + /** The key of the configuration. e.g. tag */ + String INDEX_FIELD_TAG = "index.field.tag"; + /** The key of the configuration. e.g. mimetype */ String INDEX_FIELD_MIMETYPE = "index.field.mimetype"; @@ -1358,6 +1361,27 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. 10 */ String SEARCH_HISTORY_SIZE = "search.history.size"; + /** The key of the configuration. e.g. false */ + String USER_TAG_ENABLED = "user.tag.enabled"; + + /** The key of the configuration. e.g. 50 */ + String USER_TAG_NAME_MAX_LENGTH = "user.tag.name.max.length"; + + /** The key of the configuration. e.g. 1000 */ + String USER_TAG_MAX_TAGS = "user.tag.max.tags"; + + /** The key of the configuration. e.g. 10000 */ + String USER_TAG_MAX_PATHS = "user.tag.max.paths"; + + /** The key of the configuration. e.g. 10000 */ + String USER_TAG_QUEUE_MAX_SIZE = "user.tag.queue.max.size"; + + /** The key of the configuration. e.g. 100 */ + String USER_TAG_PROCESS_BATCH_SIZE = "user.tag.process.batch.size"; + + /** The key of the configuration. e.g. 1000 */ + String USER_TAG_VISIBLE_MAX_SIZE = "user.tag.visible.max.size"; + /** The key of the configuration. e.g. 10000 */ String FORM_ADMIN_MAX_INPUT_SIZE = "form.admin.max.input.size"; @@ -1469,6 +1493,9 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. 1000 */ String PAGE_LABELTYPE_MAX_FETCH_SIZE = "page.labeltype.max.fetch.size"; + /** The key of the configuration. e.g. 1000 */ + String PAGE_TAGTYPE_MAX_FETCH_SIZE = "page.tagtype.max.fetch.size"; + /** The key of the configuration. e.g. 1000 */ String PAGE_ROLETYPE_MAX_FETCH_SIZE = "page.roletype.max.fetch.size"; @@ -1811,6 +1838,9 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. labeltype */ String ONLINE_HELP_NAME_LABELTYPE = "online.help.name.labeltype"; + /** The key of the configuration. e.g. tagtype */ + String ONLINE_HELP_NAME_TAGTYPE = "online.help.name.tagtype"; + /** The key of the configuration. e.g. duplicatehost */ String ONLINE_HELP_NAME_DUPLICATEHOST = "online.help.name.duplicatehost"; @@ -2186,7 +2216,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** The key of the configuration. e.g. /var/lib/fess/export */ String INDEX_EXPORT_PATH = "index.export.path"; - /** The key of the configuration. e.g. cache */ + /** The key of the configuration. e.g. cache,tag */ String INDEX_EXPORT_EXCLUDE_FIELDS = "index.export.exclude.fields"; /** The key of the configuration. e.g. 100 */ @@ -4823,6 +4853,14 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ String getIndexFieldLabel(); + /** + * Get the value for the key 'index.field.tag'.
    + * The value is, e.g. tag
    + * comment: Field name for the user tags of the document in the index. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getIndexFieldTag(); + /** * Get the value for the key 'index.field.mimetype'.
    * The value is, e.g. mimetype
    @@ -7178,6 +7216,124 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ Integer getSearchHistorySizeAsInteger(); + /** + * Get the value for the key 'user.tag.enabled'.
    + * The value is, e.g. false
    + * comment: Whether logged-in users can tag documents. Each tag belongs to the user who created it. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagEnabled(); + + /** + * Is the property for the key 'user.tag.enabled' true?
    + * The value is, e.g. false
    + * comment: Whether logged-in users can tag documents. Each tag belongs to the user who created it. + * @return The determination, true or false. (if not found, exception but basically no way) + */ + boolean isUserTagEnabled(); + + /** + * Get the value for the key 'user.tag.name.max.length'.
    + * The value is, e.g. 50
    + * comment: Maximum length of a tag name, in code points. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagNameMaxLength(); + + /** + * Get the value for the key 'user.tag.name.max.length' as {@link Integer}.
    + * The value is, e.g. 50
    + * comment: Maximum length of a tag name, in code points. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagNameMaxLengthAsInteger(); + + /** + * Get the value for the key 'user.tag.max.tags'.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tags one user can own. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagMaxTags(); + + /** + * Get the value for the key 'user.tag.max.tags' as {@link Integer}.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tags one user can own. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagMaxTagsAsInteger(); + + /** + * Get the value for the key 'user.tag.max.paths'.
    + * The value is, e.g. 10000
    + * comment: Maximum number of URLs one tag can be put on. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagMaxPaths(); + + /** + * Get the value for the key 'user.tag.max.paths' as {@link Integer}.
    + * The value is, e.g. 10000
    + * comment: Maximum number of URLs one tag can be put on. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagMaxPathsAsInteger(); + + /** + * Get the value for the key 'user.tag.queue.max.size'.
    + * The value is, e.g. 10000
    + * comment: Maximum number of pending tag changes held in memory until they are applied to the documents. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagQueueMaxSize(); + + /** + * Get the value for the key 'user.tag.queue.max.size' as {@link Integer}.
    + * The value is, e.g. 10000
    + * comment: Maximum number of pending tag changes held in memory until they are applied to the documents. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagQueueMaxSizeAsInteger(); + + /** + * Get the value for the key 'user.tag.process.batch.size'.
    + * The value is, e.g. 100
    + * comment: Number of URLs updated per bulk request when tag changes are applied to the documents. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagProcessBatchSize(); + + /** + * Get the value for the key 'user.tag.process.batch.size' as {@link Integer}.
    + * The value is, e.g. 100
    + * comment: Number of URLs updated per bulk request when tag changes are applied to the documents. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagProcessBatchSizeAsInteger(); + + /** + * Get the value for the key 'user.tag.visible.max.size'.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tags visible to one user in a search. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getUserTagVisibleMaxSize(); + + /** + * Get the value for the key 'user.tag.visible.max.size' as {@link Integer}.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tags visible to one user in a search. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getUserTagVisibleMaxSizeAsInteger(); + /** * Get the value for the key 'form.admin.max.input.size'.
    * The value is, e.g. 10000
    @@ -7676,6 +7832,23 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ Integer getPageLabeltypeMaxFetchSizeAsInteger(); + /** + * Get the value for the key 'page.tagtype.max.fetch.size'.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tagtype records to fetch per page. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getPageTagtypeMaxFetchSize(); + + /** + * Get the value for the key 'page.tagtype.max.fetch.size' as {@link Integer}.
    + * The value is, e.g. 1000
    + * comment: Maximum number of tagtype records to fetch per page. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + * @throws NumberFormatException When the property is not integer. + */ + Integer getPageTagtypeMaxFetchSizeAsInteger(); + /** * Get the value for the key 'page.roletype.max.fetch.size'.
    * The value is, e.g. 1000
    @@ -9207,6 +9380,14 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction */ String getOnlineHelpNameLabeltype(); + /** + * Get the value for the key 'online.help.name.tagtype'.
    + * The value is, e.g. tagtype
    + * comment: Online help key for tag type. + * @return The value of found property. (NotNull: if not found, exception but basically no way) + */ + String getOnlineHelpNameTagtype(); + /** * Get the value for the key 'online.help.name.duplicatehost'.
    * The value is, e.g. duplicatehost
    @@ -10696,7 +10877,7 @@ public interface FessConfig extends FessEnv, org.codelibs.fess.mylasta.direction /** * Get the value for the key 'index.export.exclude.fields'.
    - * The value is, e.g. cache
    + * The value is, e.g. cache,tag
    * @return The value of found property. (NotNull: if not found, exception but basically no way) */ String getIndexExportExcludeFields(); @@ -12271,6 +12452,10 @@ public String getIndexFieldLabel() { return get(FessConfig.INDEX_FIELD_LABEL); } + public String getIndexFieldTag() { + return get(FessConfig.INDEX_FIELD_TAG); + } + public String getIndexFieldMimetype() { return get(FessConfig.INDEX_FIELD_MIMETYPE); } @@ -13339,6 +13524,62 @@ public Integer getSearchHistorySizeAsInteger() { return getAsInteger(FessConfig.SEARCH_HISTORY_SIZE); } + public String getUserTagEnabled() { + return get(FessConfig.USER_TAG_ENABLED); + } + + public boolean isUserTagEnabled() { + return is(FessConfig.USER_TAG_ENABLED); + } + + public String getUserTagNameMaxLength() { + return get(FessConfig.USER_TAG_NAME_MAX_LENGTH); + } + + public Integer getUserTagNameMaxLengthAsInteger() { + return getAsInteger(FessConfig.USER_TAG_NAME_MAX_LENGTH); + } + + public String getUserTagMaxTags() { + return get(FessConfig.USER_TAG_MAX_TAGS); + } + + public Integer getUserTagMaxTagsAsInteger() { + return getAsInteger(FessConfig.USER_TAG_MAX_TAGS); + } + + public String getUserTagMaxPaths() { + return get(FessConfig.USER_TAG_MAX_PATHS); + } + + public Integer getUserTagMaxPathsAsInteger() { + return getAsInteger(FessConfig.USER_TAG_MAX_PATHS); + } + + public String getUserTagQueueMaxSize() { + return get(FessConfig.USER_TAG_QUEUE_MAX_SIZE); + } + + public Integer getUserTagQueueMaxSizeAsInteger() { + return getAsInteger(FessConfig.USER_TAG_QUEUE_MAX_SIZE); + } + + public String getUserTagProcessBatchSize() { + return get(FessConfig.USER_TAG_PROCESS_BATCH_SIZE); + } + + public Integer getUserTagProcessBatchSizeAsInteger() { + return getAsInteger(FessConfig.USER_TAG_PROCESS_BATCH_SIZE); + } + + public String getUserTagVisibleMaxSize() { + return get(FessConfig.USER_TAG_VISIBLE_MAX_SIZE); + } + + public Integer getUserTagVisibleMaxSizeAsInteger() { + return getAsInteger(FessConfig.USER_TAG_VISIBLE_MAX_SIZE); + } + public String getFormAdminMaxInputSize() { return get(FessConfig.FORM_ADMIN_MAX_INPUT_SIZE); } @@ -13571,6 +13812,14 @@ public Integer getPageLabeltypeMaxFetchSizeAsInteger() { return getAsInteger(FessConfig.PAGE_LABELTYPE_MAX_FETCH_SIZE); } + public String getPageTagtypeMaxFetchSize() { + return get(FessConfig.PAGE_TAGTYPE_MAX_FETCH_SIZE); + } + + public Integer getPageTagtypeMaxFetchSizeAsInteger() { + return getAsInteger(FessConfig.PAGE_TAGTYPE_MAX_FETCH_SIZE); + } + public String getPageRoletypeMaxFetchSize() { return get(FessConfig.PAGE_ROLETYPE_MAX_FETCH_SIZE); } @@ -14303,6 +14552,10 @@ public String getOnlineHelpNameLabeltype() { return get(FessConfig.ONLINE_HELP_NAME_LABELTYPE); } + public String getOnlineHelpNameTagtype() { + return get(FessConfig.ONLINE_HELP_NAME_TAGTYPE); + } + public String getOnlineHelpNameDuplicatehost() { return get(FessConfig.ONLINE_HELP_NAME_DUPLICATEHOST); } @@ -15406,6 +15659,7 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.INDEX_FIELD_CREATED, "created"); defaultMap.put(FessConfig.INDEX_FIELD_TIMESTAMP, "timestamp"); defaultMap.put(FessConfig.INDEX_FIELD_LABEL, "label"); + defaultMap.put(FessConfig.INDEX_FIELD_TAG, "tag"); defaultMap.put(FessConfig.INDEX_FIELD_MIMETYPE, "mimetype"); defaultMap.put(FessConfig.INDEX_FIELD_parent_id, "parent_id"); defaultMap.put(FessConfig.INDEX_FIELD_important_content, "important_content"); @@ -15585,6 +15839,13 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.LOGGING_CHAT_MAX_QUEUE_SIZE, "10000"); defaultMap.put(FessConfig.SEARCH_HISTORY_ENABLED, "true"); defaultMap.put(FessConfig.SEARCH_HISTORY_SIZE, "10"); + defaultMap.put(FessConfig.USER_TAG_ENABLED, "false"); + defaultMap.put(FessConfig.USER_TAG_NAME_MAX_LENGTH, "50"); + defaultMap.put(FessConfig.USER_TAG_MAX_TAGS, "1000"); + defaultMap.put(FessConfig.USER_TAG_MAX_PATHS, "10000"); + defaultMap.put(FessConfig.USER_TAG_QUEUE_MAX_SIZE, "10000"); + defaultMap.put(FessConfig.USER_TAG_PROCESS_BATCH_SIZE, "100"); + defaultMap.put(FessConfig.USER_TAG_VISIBLE_MAX_SIZE, "1000"); defaultMap.put(FessConfig.FORM_ADMIN_MAX_INPUT_SIZE, "10000"); defaultMap.put(FessConfig.FORM_ADMIN_LABEL_IN_CONFIG_ENABLED, "false"); defaultMap.put(FessConfig.FORM_ADMIN_DEFAULT_TEMPLATE_NAME, "__TEMPLATE__"); @@ -15621,6 +15882,7 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.PAGE_DOCBOOST_MAX_FETCH_SIZE, "1000"); defaultMap.put(FessConfig.PAGE_KEYMATCH_MAX_FETCH_SIZE, "1000"); defaultMap.put(FessConfig.PAGE_LABELTYPE_MAX_FETCH_SIZE, "1000"); + defaultMap.put(FessConfig.PAGE_TAGTYPE_MAX_FETCH_SIZE, "1000"); defaultMap.put(FessConfig.PAGE_ROLETYPE_MAX_FETCH_SIZE, "1000"); defaultMap.put(FessConfig.PAGE_USER_MAX_FETCH_SIZE, "1000"); defaultMap.put(FessConfig.PAGE_ROLE_MAX_FETCH_SIZE, "1000"); @@ -15735,6 +15997,7 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.ONLINE_HELP_NAME_FILECONFIG, "fileconfig"); defaultMap.put(FessConfig.ONLINE_HELP_NAME_FILEAUTH, "fileauth"); defaultMap.put(FessConfig.ONLINE_HELP_NAME_LABELTYPE, "labeltype"); + defaultMap.put(FessConfig.ONLINE_HELP_NAME_TAGTYPE, "tagtype"); defaultMap.put(FessConfig.ONLINE_HELP_NAME_DUPLICATEHOST, "duplicatehost"); defaultMap.put(FessConfig.ONLINE_HELP_NAME_SCHEDULER, "scheduler"); defaultMap.put(FessConfig.ONLINE_HELP_NAME_CRAWLINGINFO, "crawlinginfo"); @@ -15861,7 +16124,7 @@ protected java.util.Map prepareGeneratedDefaultMap() { defaultMap.put(FessConfig.RAG_CHAT_DOCUMENT_MAX_PARTS, "10"); defaultMap.put(FessConfig.RAG_CHAT_RESPONSE_LANGUAGE, "browser"); defaultMap.put(FessConfig.INDEX_EXPORT_PATH, "/var/lib/fess/export"); - defaultMap.put(FessConfig.INDEX_EXPORT_EXCLUDE_FIELDS, "cache"); + defaultMap.put(FessConfig.INDEX_EXPORT_EXCLUDE_FIELDS, "cache,tag"); defaultMap.put(FessConfig.INDEX_EXPORT_SCROLL_SIZE, "100"); defaultMap.put(FessConfig.INDEX_EXPORT_FORMAT, "html"); defaultMap.put(FessConfig.LOG_NOTIFICATION_FLUSH_INTERVAL, "30"); diff --git a/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java b/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java index 7081b9b138..6649b6a15d 100644 --- a/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java +++ b/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java @@ -1231,7 +1231,8 @@ public void addMapping(final String index, final String docType, final String in * never receives fields that a new release adds to it. The first write of such a field would * map it dynamically, and the intended type could then only be applied by a reindex. Fields * the index already has are never changed. The document index is skipped: its mapping is - * rewritten by plugins and is upgraded by a reindex.

    + * rewritten by plugins and is upgraded by a reindex. Only its tag field is added, by + * {@link #addMissingTagProperty(String, MappingMetadata)}.

    * *

    Only the webapp process ({@link #isWebappProcess()}) adds the fields. The webapp opens the * client before it starts any job process, so the fields are already there when a crawler, @@ -1245,7 +1246,11 @@ public void addMapping(final String index, final String docType, final String in */ protected void addMissingProperties(final String index, final String docType, final String indexName, final MappingMetadata existingProperties) { - if (DOC_INDEX.equals(index) || !isWebappProcess()) { + if (!isWebappProcess()) { + return; + } + if (DOC_INDEX.equals(index)) { + addMissingTagProperty(indexName, existingProperties); return; } final FessConfig fessConfig = ComponentUtil.getFessConfig(); @@ -1281,6 +1286,40 @@ protected void addMissingProperties(final String index, final String docType, fi } } + /** + * Adds the user tag field to an existing document index that does not have it yet. + * + *

    The field was added to the bundled document mapping after release, and an existing index + * would map it dynamically as text on the first tag write, which breaks the terms facet and + * the term filters on it. Only this one field is put, as {@code keyword}. A tag field the index + * already has is never changed; one of another type is reported, since only a reindex can + * change it.

    + * + * @param indexName the actual document index name + * @param existingProperties the index's current {@code properties} mapping + */ + protected void addMissingTagProperty(final String indexName, final MappingMetadata existingProperties) { + final String tagField = ComponentUtil.getFessConfig().getIndexFieldTag(); + final Object existing = existingProperties.sourceAsMap().get(tagField); + if (existing != null) { + final Object type = existing instanceof final Map definition ? definition.get("type") : null; + if (!"keyword".equals(type)) { + logger.warn("The {} field of {} is not mapped as keyword; reindex to use user tags: type={}", tagField, indexName, type); + } + return; + } + final Map properties = Collections.singletonMap(tagField, Collections.singletonMap("type", "keyword")); + try { + if (putMapping(indexName, new ObjectMapper().writeValueAsString(Collections.singletonMap("properties", properties)))) { + logger.info("Added fields to {} mapping: fields={}", indexName, properties.keySet()); + } else { + logger.warn("Failed to add fields to {} mapping: fields={}", indexName, properties.keySet()); + } + } catch (final Exception e) { + logger.warn("Failed to add fields to {} mapping: fields={}", indexName, properties.keySet(), e); + } + } + /** * Returns the bundled properties whose names the existing mapping does not have. * diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsTagTypeBhv.java b/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsTagTypeBhv.java new file mode 100644 index 0000000000..1fe43eb0d8 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsbhv/BsTagTypeBhv.java @@ -0,0 +1,266 @@ +/* + * Copyright 2012-2025 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.opensearch.config.bsbhv; + +import java.util.List; +import java.util.Map; + +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractBehavior; +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractEntity.RequestOptionCall; +import org.codelibs.fess.opensearch.config.bsentity.dbmeta.TagTypeDbm; +import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.dbflute.Entity; +import org.dbflute.bhv.readable.CBCall; +import org.dbflute.bhv.readable.EntityRowHandler; +import org.dbflute.cbean.ConditionBean; +import org.dbflute.cbean.result.ListResultBean; +import org.dbflute.cbean.result.PagingResultBean; +import org.dbflute.exception.IllegalBehaviorStateException; +import org.dbflute.optional.OptionalEntity; +import org.dbflute.util.DfTypeUtil; +import org.codelibs.fesen.opensearch.action.bulk.BulkRequestBuilder; +import org.codelibs.fesen.opensearch.action.delete.DeleteRequestBuilder; +import org.codelibs.fesen.opensearch.action.index.IndexRequestBuilder; + +/** + * @author ESFlute (using FreeGen) + */ +public abstract class BsTagTypeBhv extends EsAbstractBehavior { + + // =================================================================================== + // Control Override + // ================ + @Override + public String asTableDbName() { + return asEsIndexType(); + } + + @Override + protected String asEsIndex() { + return "fess_config.tag_type"; + } + + @Override + public String asEsIndexType() { + return "tag_type"; + } + + @Override + public String asEsSearchType() { + return "tag_type"; + } + + @Override + public TagTypeDbm asDBMeta() { + return TagTypeDbm.getInstance(); + } + + @Override + protected RESULT createEntity(Map source, Class entityType) { + try { + final RESULT result = entityType.newInstance(); + result.setCreatedBy(DfTypeUtil.toString(source.get("createdBy"))); + result.setCreatedTime(DfTypeUtil.toLong(source.get("createdTime"))); + result.setName(DfTypeUtil.toString(source.get("name"))); + result.setOwner(DfTypeUtil.toString(source.get("owner"))); + result.setPaths(toStringArray(source.get("paths"))); + result.setPermissions(toStringArray(source.get("permissions"))); + result.setSortOrder(DfTypeUtil.toInteger(source.get("sortOrder"))); + result.setUpdatedBy(DfTypeUtil.toString(source.get("updatedBy"))); + result.setUpdatedTime(DfTypeUtil.toLong(source.get("updatedTime"))); + result.setVirtualHost(DfTypeUtil.toString(source.get("virtualHost"))); + return updateEntity(source, result); + } catch (InstantiationException | IllegalAccessException e) { + final String msg = "Cannot create a new instance: " + entityType.getName(); + throw new IllegalBehaviorStateException(msg, e); + } + } + + protected RESULT updateEntity(Map source, RESULT result) { + return result; + } + + // =================================================================================== + // Select + // ====== + public int selectCount(CBCall cbLambda) { + return facadeSelectCount(createCB(cbLambda)); + } + + public OptionalEntity selectEntity(CBCall cbLambda) { + return facadeSelectEntity(createCB(cbLambda)); + } + + protected OptionalEntity facadeSelectEntity(TagTypeCB cb) { + return doSelectOptionalEntity(cb, typeOfSelectedEntity()); + } + + protected OptionalEntity doSelectOptionalEntity(TagTypeCB cb, Class tp) { + return createOptionalEntity(doSelectEntity(cb, tp), cb); + } + + @Override + public TagTypeCB newConditionBean() { + return new TagTypeCB(); + } + + @Override + protected Entity doReadEntity(ConditionBean cb) { + return facadeSelectEntity(downcast(cb)).orElse(null); + } + + public TagType selectEntityWithDeletedCheck(CBCall cbLambda) { + return facadeSelectEntityWithDeletedCheck(createCB(cbLambda)); + } + + public OptionalEntity selectByPK(String id) { + return facadeSelectByPK(id); + } + + protected OptionalEntity facadeSelectByPK(String id) { + return doSelectOptionalByPK(id, typeOfSelectedEntity()); + } + + protected ENTITY doSelectByPK(String id, Class tp) { + return doSelectEntity(xprepareCBAsPK(id), tp); + } + + protected TagTypeCB xprepareCBAsPK(String id) { + assertObjectNotNull("id", id); + return newConditionBean().acceptPK(id); + } + + protected OptionalEntity doSelectOptionalByPK(String id, Class tp) { + return createOptionalEntity(doSelectByPK(id, tp), id); + } + + @Override + protected Class typeOfSelectedEntity() { + return TagType.class; + } + + @Override + protected Class typeOfHandlingEntity() { + return TagType.class; + } + + @Override + protected Class typeOfHandlingConditionBean() { + return TagTypeCB.class; + } + + public ListResultBean selectList(CBCall cbLambda) { + return facadeSelectList(createCB(cbLambda)); + } + + public PagingResultBean selectPage(CBCall cbLambda) { + // #pending same? + return (PagingResultBean) facadeSelectList(createCB(cbLambda)); + } + + public void selectCursor(CBCall cbLambda, EntityRowHandler entityLambda) { + facadeSelectCursor(createCB(cbLambda), entityLambda); + } + + public void selectBulk(CBCall cbLambda, EntityRowHandler> entityLambda) { + delegateSelectBulk(createCB(cbLambda), entityLambda, typeOfSelectedEntity()); + } + + // =================================================================================== + // Update + // ====== + public void insert(TagType entity) { + doInsert(entity, null); + } + + public void insert(TagType entity, RequestOptionCall opLambda) { + entity.asDocMeta().indexOption(opLambda); + doInsert(entity, null); + } + + public void update(TagType entity) { + doUpdate(entity, null); + } + + public void update(TagType entity, RequestOptionCall opLambda) { + entity.asDocMeta().indexOption(opLambda); + doUpdate(entity, null); + } + + public void insertOrUpdate(TagType entity) { + doInsertOrUpdate(entity, null, null); + } + + public void insertOrUpdate(TagType entity, RequestOptionCall opLambda) { + entity.asDocMeta().indexOption(opLambda); + doInsertOrUpdate(entity, null, null); + } + + public void delete(TagType entity) { + doDelete(entity, null); + } + + public void delete(TagType entity, RequestOptionCall opLambda) { + entity.asDocMeta().deleteOption(opLambda); + doDelete(entity, null); + } + + public int queryDelete(CBCall cbLambda) { + return doQueryDelete(createCB(cbLambda), null); + } + + public int[] batchInsert(List list) { + return batchInsert(list, null, null); + } + + public int[] batchInsert(List list, RequestOptionCall call) { + return batchInsert(list, call, null); + } + + public int[] batchInsert(List list, RequestOptionCall call, + RequestOptionCall entityCall) { + return doBatchInsert(new BulkList<>(list, call, entityCall), null); + } + + public int[] batchUpdate(List list) { + return batchUpdate(list, null, null); + } + + public int[] batchUpdate(List list, RequestOptionCall call) { + return batchUpdate(list, call, null); + } + + public int[] batchUpdate(List list, RequestOptionCall call, + RequestOptionCall entityCall) { + return doBatchUpdate(new BulkList<>(list, call, entityCall), null); + } + + public int[] batchDelete(List list) { + return batchDelete(list, null, null); + } + + public int[] batchDelete(List list, RequestOptionCall call) { + return batchDelete(list, call, null); + } + + public int[] batchDelete(List list, RequestOptionCall call, + RequestOptionCall entityCall) { + return doBatchDelete(new BulkList<>(list, call, entityCall), null); + } + + // #pending create, modify, remove +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsTagType.java b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsTagType.java new file mode 100644 index 0000000000..4efa8b0e12 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/BsTagType.java @@ -0,0 +1,253 @@ +/* + * Copyright 2012-2025 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.opensearch.config.bsentity; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractEntity; +import org.codelibs.fess.opensearch.config.bsentity.dbmeta.TagTypeDbm; + +/** + * ${table.comment} + * @author ESFlute (using FreeGen) + */ +public class BsTagType extends EsAbstractEntity { + + // =================================================================================== + // Definition + // ========== + private static final long serialVersionUID = 1L; + protected static final Class suppressUnusedImportLocalDateTime = LocalDateTime.class; + + // =================================================================================== + // Attribute + // ========= + /** createdBy */ + protected String createdBy; + + /** createdTime */ + protected Long createdTime; + + /** name */ + protected String name; + + /** owner */ + protected String owner; + + /** paths */ + protected String[] paths; + + /** permissions */ + protected String[] permissions; + + /** sortOrder */ + protected Integer sortOrder; + + /** updatedBy */ + protected String updatedBy; + + /** updatedTime */ + protected Long updatedTime; + + /** virtualHost */ + protected String virtualHost; + + // [Referrers] *comment only + + // =================================================================================== + // DB Meta + // ======= + @Override + public TagTypeDbm asDBMeta() { + return TagTypeDbm.getInstance(); + } + + @Override + public String asTableDbName() { + return "tag_type"; + } + + // =================================================================================== + // Source + // ====== + @Override + public Map toSource() { + Map sourceMap = new HashMap<>(); + if (createdBy != null) { + addFieldToSource(sourceMap, "createdBy", createdBy); + } + if (createdTime != null) { + addFieldToSource(sourceMap, "createdTime", createdTime); + } + if (name != null) { + addFieldToSource(sourceMap, "name", name); + } + if (owner != null) { + addFieldToSource(sourceMap, "owner", owner); + } + if (paths != null) { + addFieldToSource(sourceMap, "paths", paths); + } + if (permissions != null) { + addFieldToSource(sourceMap, "permissions", permissions); + } + if (sortOrder != null) { + addFieldToSource(sourceMap, "sortOrder", sortOrder); + } + if (updatedBy != null) { + addFieldToSource(sourceMap, "updatedBy", updatedBy); + } + if (updatedTime != null) { + addFieldToSource(sourceMap, "updatedTime", updatedTime); + } + if (virtualHost != null) { + addFieldToSource(sourceMap, "virtualHost", virtualHost); + } + return sourceMap; + } + + protected void addFieldToSource(Map sourceMap, String field, Object value) { + sourceMap.put(field, value); + } + + // =================================================================================== + // Basic Override + // ============== + @Override + protected String doBuildColumnString(String dm) { + StringBuilder sb = new StringBuilder(); + sb.append(dm).append(createdBy); + sb.append(dm).append(createdTime); + sb.append(dm).append(name); + sb.append(dm).append(owner); + sb.append(dm).append(paths); + sb.append(dm).append(permissions); + sb.append(dm).append(sortOrder); + sb.append(dm).append(updatedBy); + sb.append(dm).append(updatedTime); + sb.append(dm).append(virtualHost); + if (sb.length() > dm.length()) { + sb.delete(0, dm.length()); + } + sb.insert(0, "{").append("}"); + return sb.toString(); + } + + // =================================================================================== + // Accessor + // ======== + public String getCreatedBy() { + checkSpecifiedProperty("createdBy"); + return convertEmptyToNull(createdBy); + } + + public void setCreatedBy(String value) { + registerModifiedProperty("createdBy"); + this.createdBy = value; + } + + public Long getCreatedTime() { + checkSpecifiedProperty("createdTime"); + return createdTime; + } + + public void setCreatedTime(Long value) { + registerModifiedProperty("createdTime"); + this.createdTime = value; + } + + public String getName() { + checkSpecifiedProperty("name"); + return convertEmptyToNull(name); + } + + public void setName(String value) { + registerModifiedProperty("name"); + this.name = value; + } + + public String getOwner() { + checkSpecifiedProperty("owner"); + return convertEmptyToNull(owner); + } + + public void setOwner(String value) { + registerModifiedProperty("owner"); + this.owner = value; + } + + public String[] getPaths() { + checkSpecifiedProperty("paths"); + return paths; + } + + public void setPaths(String[] value) { + registerModifiedProperty("paths"); + this.paths = value; + } + + public String[] getPermissions() { + checkSpecifiedProperty("permissions"); + return permissions; + } + + public void setPermissions(String[] value) { + registerModifiedProperty("permissions"); + this.permissions = value; + } + + public Integer getSortOrder() { + checkSpecifiedProperty("sortOrder"); + return sortOrder; + } + + public void setSortOrder(Integer value) { + registerModifiedProperty("sortOrder"); + this.sortOrder = value; + } + + public String getUpdatedBy() { + checkSpecifiedProperty("updatedBy"); + return convertEmptyToNull(updatedBy); + } + + public void setUpdatedBy(String value) { + registerModifiedProperty("updatedBy"); + this.updatedBy = value; + } + + public Long getUpdatedTime() { + checkSpecifiedProperty("updatedTime"); + return updatedTime; + } + + public void setUpdatedTime(Long value) { + registerModifiedProperty("updatedTime"); + this.updatedTime = value; + } + + public String getVirtualHost() { + checkSpecifiedProperty("virtualHost"); + return convertEmptyToNull(virtualHost); + } + + public void setVirtualHost(String value) { + registerModifiedProperty("virtualHost"); + this.virtualHost = value; + } +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/TagTypeDbm.java b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/TagTypeDbm.java new file mode 100644 index 0000000000..230aabc577 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/bsentity/dbmeta/TagTypeDbm.java @@ -0,0 +1,282 @@ +/* + * Copyright 2012-2025 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.opensearch.config.bsentity.dbmeta; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.dbflute.Entity; +import org.dbflute.dbmeta.AbstractDBMeta; +import org.dbflute.dbmeta.info.ColumnInfo; +import org.dbflute.dbmeta.info.UniqueInfo; +import org.dbflute.dbmeta.name.TableSqlName; +import org.dbflute.dbmeta.property.PropertyGateway; +import org.dbflute.dbway.DBDef; +import org.dbflute.util.DfTypeUtil; + +/** + * @author ESFlute (using FreeGen) + */ +public class TagTypeDbm extends AbstractDBMeta { + + protected static final Class suppressUnusedImportLocalDateTime = LocalDateTime.class; + + // =================================================================================== + // Singleton + // ========= + private static final TagTypeDbm _instance = new TagTypeDbm(); + + private TagTypeDbm() { + } + + public static TagTypeDbm getInstance() { + return _instance; + } + + // =================================================================================== + // Current DBDef + // ============= + @Override + public String getProjectName() { + return null; + } + + @Override + public String getProjectPrefix() { + return null; + } + + @Override + public String getGenerationGapBasePrefix() { + return null; + } + + @Override + public DBDef getCurrentDBDef() { + return null; + } + + // =================================================================================== + // Property Gateway + // ================ + // ----------------------------------------------------- + // Column Property + // --------------- + protected final Map _epgMap = newHashMap(); + { + setupEpg(_epgMap, et -> ((TagType) et).getCreatedBy(), (et, vl) -> ((TagType) et).setCreatedBy(DfTypeUtil.toString(vl)), + "createdBy"); + setupEpg(_epgMap, et -> ((TagType) et).getCreatedTime(), (et, vl) -> ((TagType) et).setCreatedTime(DfTypeUtil.toLong(vl)), + "createdTime"); + setupEpg(_epgMap, et -> ((TagType) et).getName(), (et, vl) -> ((TagType) et).setName(DfTypeUtil.toString(vl)), "name"); + setupEpg(_epgMap, et -> ((TagType) et).getOwner(), (et, vl) -> ((TagType) et).setOwner(DfTypeUtil.toString(vl)), "owner"); + setupEpg(_epgMap, et -> ((TagType) et).getPaths(), (et, vl) -> ((TagType) et).setPaths((String[]) vl), "paths"); + setupEpg(_epgMap, et -> ((TagType) et).getPermissions(), (et, vl) -> ((TagType) et).setPermissions((String[]) vl), "permissions"); + setupEpg(_epgMap, et -> ((TagType) et).getSortOrder(), (et, vl) -> ((TagType) et).setSortOrder(DfTypeUtil.toInteger(vl)), + "sortOrder"); + setupEpg(_epgMap, et -> ((TagType) et).getUpdatedBy(), (et, vl) -> ((TagType) et).setUpdatedBy(DfTypeUtil.toString(vl)), + "updatedBy"); + setupEpg(_epgMap, et -> ((TagType) et).getUpdatedTime(), (et, vl) -> ((TagType) et).setUpdatedTime(DfTypeUtil.toLong(vl)), + "updatedTime"); + setupEpg(_epgMap, et -> ((TagType) et).getVirtualHost(), (et, vl) -> ((TagType) et).setVirtualHost(DfTypeUtil.toString(vl)), + "virtualHost"); + } + + @Override + public PropertyGateway findPropertyGateway(final String prop) { + return doFindEpg(_epgMap, prop); + } + + // =================================================================================== + // Table Info + // ========== + protected final String _tableDbName = "tag_type"; + protected final String _tableDispName = "tag_type"; + protected final String _tablePropertyName = "TagType"; + + public String getTableDbName() { + return _tableDbName; + } + + @Override + public String getTableDispName() { + return _tableDispName; + } + + @Override + public String getTablePropertyName() { + return _tablePropertyName; + } + + @Override + public TableSqlName getTableSqlName() { + return null; + } + + // =================================================================================== + // Column Info + // =========== + protected final ColumnInfo _columnCreatedBy = cci("createdBy", "createdBy", null, null, String.class, "createdBy", null, false, false, + false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnCreatedTime = cci("createdTime", "createdTime", null, null, Long.class, "createdTime", null, false, + false, false, "Long", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnName = cci("name", "name", null, null, String.class, "name", null, false, false, false, "keyword", 0, + 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnOwner = cci("owner", "owner", null, null, String.class, "owner", null, false, false, false, "keyword", + 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnPaths = cci("paths", "paths", null, null, String[].class, "paths", null, false, false, false, + "keyword", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnPermissions = cci("permissions", "permissions", null, null, String[].class, "permissions", null, + false, false, false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnSortOrder = cci("sortOrder", "sortOrder", null, null, Integer.class, "sortOrder", null, false, false, + false, "Integer", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnUpdatedBy = cci("updatedBy", "updatedBy", null, null, String.class, "updatedBy", null, false, false, + false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnUpdatedTime = cci("updatedTime", "updatedTime", null, null, Long.class, "updatedTime", null, false, + false, false, "Long", 0, 0, null, null, false, null, null, null, null, null, false); + protected final ColumnInfo _columnVirtualHost = cci("virtualHost", "virtualHost", null, null, String.class, "virtualHost", null, false, + false, false, "keyword", 0, 0, null, null, false, null, null, null, null, null, false); + + public ColumnInfo columnCreatedBy() { + return _columnCreatedBy; + } + + public ColumnInfo columnCreatedTime() { + return _columnCreatedTime; + } + + public ColumnInfo columnName() { + return _columnName; + } + + public ColumnInfo columnOwner() { + return _columnOwner; + } + + public ColumnInfo columnPaths() { + return _columnPaths; + } + + public ColumnInfo columnPermissions() { + return _columnPermissions; + } + + public ColumnInfo columnSortOrder() { + return _columnSortOrder; + } + + public ColumnInfo columnUpdatedBy() { + return _columnUpdatedBy; + } + + public ColumnInfo columnUpdatedTime() { + return _columnUpdatedTime; + } + + public ColumnInfo columnVirtualHost() { + return _columnVirtualHost; + } + + protected List ccil() { + List ls = newArrayList(); + ls.add(columnCreatedBy()); + ls.add(columnCreatedTime()); + ls.add(columnName()); + ls.add(columnOwner()); + ls.add(columnPaths()); + ls.add(columnPermissions()); + ls.add(columnSortOrder()); + ls.add(columnUpdatedBy()); + ls.add(columnUpdatedTime()); + ls.add(columnVirtualHost()); + return ls; + } + + // =================================================================================== + // Unique Info + // =========== + @Override + public boolean hasPrimaryKey() { + return false; + } + + @Override + public boolean hasCompoundPrimaryKey() { + return false; + } + + @Override + protected UniqueInfo cpui() { + return null; + } + + // =================================================================================== + // Type Name + // ========= + @Override + public String getEntityTypeName() { + return "org.codelibs.fess.opensearch.config.exentity.TagType"; + } + + @Override + public String getConditionBeanTypeName() { + return "org.codelibs.fess.opensearch.config.cbean.TagTypeCB"; + } + + @Override + public String getBehaviorTypeName() { + return "org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv"; + } + + // =================================================================================== + // Object Type + // =========== + @Override + public Class getEntityType() { + return TagType.class; + } + + // =================================================================================== + // Object Instance + // =============== + @Override + public Entity newEntity() { + return new TagType(); + } + + // =================================================================================== + // Map Communication + // ================= + @Override + public void acceptPrimaryKeyMap(Entity entity, Map primaryKeyMap) { + } + + @Override + public void acceptAllColumnMap(Entity entity, Map allColumnMap) { + } + + @Override + public Map extractPrimaryKeyMap(Entity entity) { + return null; + } + + @Override + public Map extractAllColumnMap(Entity entity) { + return null; + } +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/TagTypeCB.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/TagTypeCB.java new file mode 100644 index 0000000000..8e53593cae --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/TagTypeCB.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean; + +import org.codelibs.fess.opensearch.config.cbean.bs.BsTagTypeCB; + +/** + * @author ESFlute (using FreeGen) + */ +public class TagTypeCB extends BsTagTypeCB { +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsTagTypeCB.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsTagTypeCB.java new file mode 100644 index 0000000000..21b4e39852 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/bs/BsTagTypeCB.java @@ -0,0 +1,219 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean.bs; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractConditionBean; +import org.codelibs.fess.opensearch.config.bsentity.dbmeta.TagTypeDbm; +import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; +import org.codelibs.fess.opensearch.config.cbean.ca.TagTypeCA; +import org.codelibs.fess.opensearch.config.cbean.ca.bs.BsTagTypeCA; +import org.codelibs.fess.opensearch.config.cbean.cq.TagTypeCQ; +import org.codelibs.fess.opensearch.config.cbean.cq.bs.BsTagTypeCQ; +import org.dbflute.cbean.ConditionQuery; +import org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder; +import org.codelibs.fesen.opensearch.index.query.QueryBuilder; + +/** + * @author ESFlute (using FreeGen) + */ +public class BsTagTypeCB extends EsAbstractConditionBean { + + // =================================================================================== + // Attribute + // ========= + protected BsTagTypeCQ _conditionQuery; + protected BsTagTypeCA _conditionAggregation; + protected HpSpecification _specification; + + // =================================================================================== + // Control + // ======= + @Override + public TagTypeDbm asDBMeta() { + return TagTypeDbm.getInstance(); + } + + @Override + public String asTableDbName() { + return "tag_type"; + } + + @Override + public boolean hasSpecifiedColumn() { + return _specification != null; + } + + @Override + public ConditionQuery localCQ() { + return doGetConditionQuery(); + } + + // =================================================================================== + // Primary Key + // =========== + public TagTypeCB acceptPK(String id) { + assertObjectNotNull("id", id); + BsTagTypeCB cb = this; + cb.query().docMeta().setId_Equal(id); + return (TagTypeCB) this; + } + + @Override + public void acceptPrimaryKeyMap(Map primaryKeyMap) { + acceptPK((String) primaryKeyMap.get("_id")); + } + + // =================================================================================== + // Build + // ===== + + @Override + public SearchRequestBuilder build(SearchRequestBuilder builder) { + if (_conditionQuery != null) { + QueryBuilder queryBuilder = _conditionQuery.getQuery(); + if (queryBuilder != null) { + builder.setQuery(queryBuilder); + } + _conditionQuery.getFieldSortBuilderList().forEach(sort -> { + builder.addSort(sort); + }); + } + + if (_conditionAggregation != null) { + _conditionAggregation.getAggregationBuilderList().forEach(builder::addAggregation); + } + + if (_specification != null) { + builder.setFetchSource(_specification.columnList.toArray(new String[_specification.columnList.size()]), null); + } + + return builder; + } + + // =================================================================================== + // Query + // ===== + public BsTagTypeCQ query() { + assertQueryPurpose(); + return doGetConditionQuery(); + } + + protected BsTagTypeCQ doGetConditionQuery() { + if (_conditionQuery == null) { + _conditionQuery = createLocalCQ(); + } + return _conditionQuery; + } + + protected BsTagTypeCQ createLocalCQ() { + return new TagTypeCQ(); + } + + // =================================================================================== + // Aggregation + // =========== + public BsTagTypeCA aggregation() { + assertAggregationPurpose(); + return doGetConditionAggregation(); + } + + protected BsTagTypeCA doGetConditionAggregation() { + if (_conditionAggregation == null) { + _conditionAggregation = createLocalCA(); + } + return _conditionAggregation; + } + + protected BsTagTypeCA createLocalCA() { + return new TagTypeCA(); + } + + // =================================================================================== + // Specify + // ======= + public HpSpecification specify() { + assertSpecifyPurpose(); + if (_specification == null) { + _specification = new HpSpecification(); + } + return _specification; + } + + protected void assertQueryPurpose() { + } + + protected void assertAggregationPurpose() { + } + + protected void assertSpecifyPurpose() { + } + + public static class HpSpecification { + protected List columnList = new ArrayList<>(); + + public void doColumn(String name) { + columnList.add(name); + } + + public void columnId() { + doColumn("_id"); + } + + public void columnCreatedBy() { + doColumn("createdBy"); + } + + public void columnCreatedTime() { + doColumn("createdTime"); + } + + public void columnName() { + doColumn("name"); + } + + public void columnOwner() { + doColumn("owner"); + } + + public void columnPaths() { + doColumn("paths"); + } + + public void columnPermissions() { + doColumn("permissions"); + } + + public void columnSortOrder() { + doColumn("sortOrder"); + } + + public void columnUpdatedBy() { + doColumn("updatedBy"); + } + + public void columnUpdatedTime() { + doColumn("updatedTime"); + } + + public void columnVirtualHost() { + doColumn("virtualHost"); + } + } +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/TagTypeCA.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/TagTypeCA.java new file mode 100644 index 0000000000..59fe7435dc --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/TagTypeCA.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean.ca; + +import org.codelibs.fess.opensearch.config.cbean.ca.bs.BsTagTypeCA; + +/** + * @author ESFlute (using FreeGen) + */ +public class TagTypeCA extends BsTagTypeCA { +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsTagTypeCA.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsTagTypeCA.java new file mode 100644 index 0000000000..9895091002 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/ca/bs/BsTagTypeCA.java @@ -0,0 +1,1701 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean.ca.bs; + +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractConditionAggregation; +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractConditionQuery; +import org.codelibs.fess.opensearch.config.cbean.ca.TagTypeCA; +import org.codelibs.fess.opensearch.config.cbean.cq.TagTypeCQ; +import org.codelibs.fess.opensearch.config.cbean.cq.bs.BsTagTypeCQ; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.filter.FilterAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.global.GlobalAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.histogram.HistogramAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.missing.MissingAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.range.IpRangeAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.range.RangeAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.sampler.SamplerAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.SignificantTermsAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.AvgAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.CardinalityAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.ExtendedStatsAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.MaxAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.MinAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.PercentileRanksAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.PercentilesAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.ScriptedMetricAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.StatsAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.SumAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.TopHitsAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.ValueCountAggregationBuilder; + +/** + * @author ESFlute (using FreeGen) + */ +public abstract class BsTagTypeCA extends EsAbstractConditionAggregation { + + // =================================================================================== + // Aggregation Set + // ========= + + public void filter(String name, EsAbstractConditionQuery.OperatorCall queryLambda, + ConditionOptionCall opLambda, OperatorCall aggsLambda) { + TagTypeCQ cq = new TagTypeCQ(); + if (queryLambda != null) { + queryLambda.callback(cq); + } + FilterAggregationBuilder builder = regFilterA(name, cq.getQuery()); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void global(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { + GlobalAggregationBuilder builder = regGlobalA(name); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void sampler(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { + SamplerAggregationBuilder builder = regSamplerA(name); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void scriptedMetric(String name, ConditionOptionCall opLambda) { + ScriptedMetricAggregationBuilder builder = regScriptedMetricA(name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void topHits(String name, ConditionOptionCall opLambda) { + TopHitsAggregationBuilder builder = regTopHitsA(name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Terms() { + setCreatedBy_Terms(null); + } + + public void setCreatedBy_Terms(ConditionOptionCall opLambda) { + setCreatedBy_Terms("createdBy", opLambda, null); + } + + public void setCreatedBy_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedBy_Terms("createdBy", opLambda, aggsLambda); + } + + public void setCreatedBy_Terms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedBy_SignificantTerms() { + setCreatedBy_SignificantTerms(null); + } + + public void setCreatedBy_SignificantTerms(ConditionOptionCall opLambda) { + setCreatedBy_SignificantTerms("createdBy", opLambda, null); + } + + public void setCreatedBy_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setCreatedBy_SignificantTerms("createdBy", opLambda, aggsLambda); + } + + public void setCreatedBy_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedBy_IpRange() { + setCreatedBy_IpRange(null); + } + + public void setCreatedBy_IpRange(ConditionOptionCall opLambda) { + setCreatedBy_IpRange("createdBy", opLambda, null); + } + + public void setCreatedBy_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedBy_IpRange("createdBy", opLambda, aggsLambda); + } + + public void setCreatedBy_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedBy_Count() { + setCreatedBy_Count(null); + } + + public void setCreatedBy_Count(ConditionOptionCall opLambda) { + setCreatedBy_Count("createdBy", opLambda); + } + + public void setCreatedBy_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Cardinality() { + setCreatedBy_Cardinality(null); + } + + public void setCreatedBy_Cardinality(ConditionOptionCall opLambda) { + setCreatedBy_Cardinality("createdBy", opLambda); + } + + public void setCreatedBy_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Missing() { + setCreatedBy_Missing(null); + } + + public void setCreatedBy_Missing(ConditionOptionCall opLambda) { + setCreatedBy_Missing("createdBy", opLambda, null); + } + + public void setCreatedBy_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedBy_Missing("createdBy", opLambda, aggsLambda); + } + + public void setCreatedBy_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedTime_Avg() { + setCreatedTime_Avg(null); + } + + public void setCreatedTime_Avg(ConditionOptionCall opLambda) { + setCreatedTime_Avg("createdTime", opLambda); + } + + public void setCreatedTime_Avg(String name, ConditionOptionCall opLambda) { + AvgAggregationBuilder builder = regAvgA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Max() { + setCreatedTime_Max(null); + } + + public void setCreatedTime_Max(ConditionOptionCall opLambda) { + setCreatedTime_Max("createdTime", opLambda); + } + + public void setCreatedTime_Max(String name, ConditionOptionCall opLambda) { + MaxAggregationBuilder builder = regMaxA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Min() { + setCreatedTime_Min(null); + } + + public void setCreatedTime_Min(ConditionOptionCall opLambda) { + setCreatedTime_Min("createdTime", opLambda); + } + + public void setCreatedTime_Min(String name, ConditionOptionCall opLambda) { + MinAggregationBuilder builder = regMinA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Sum() { + setCreatedTime_Sum(null); + } + + public void setCreatedTime_Sum(ConditionOptionCall opLambda) { + setCreatedTime_Sum("createdTime", opLambda); + } + + public void setCreatedTime_Sum(String name, ConditionOptionCall opLambda) { + SumAggregationBuilder builder = regSumA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_ExtendedStats() { + setCreatedTime_ExtendedStats(null); + } + + public void setCreatedTime_ExtendedStats(ConditionOptionCall opLambda) { + setCreatedTime_ExtendedStats("createdTime", opLambda); + } + + public void setCreatedTime_ExtendedStats(String name, ConditionOptionCall opLambda) { + ExtendedStatsAggregationBuilder builder = regExtendedStatsA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Stats() { + setCreatedTime_Stats(null); + } + + public void setCreatedTime_Stats(ConditionOptionCall opLambda) { + setCreatedTime_Stats("createdTime", opLambda); + } + + public void setCreatedTime_Stats(String name, ConditionOptionCall opLambda) { + StatsAggregationBuilder builder = regStatsA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Percentiles() { + setCreatedTime_Percentiles(null); + } + + public void setCreatedTime_Percentiles(ConditionOptionCall opLambda) { + setCreatedTime_Percentiles("createdTime", opLambda); + } + + public void setCreatedTime_Percentiles(String name, ConditionOptionCall opLambda) { + PercentilesAggregationBuilder builder = regPercentilesA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_PercentileRanks(double[] values) { + setCreatedTime_PercentileRanks(values, null); + } + + public void setCreatedTime_PercentileRanks(double[] values, ConditionOptionCall opLambda) { + setCreatedTime_PercentileRanks("createdTime", values, opLambda); + } + + public void setCreatedTime_PercentileRanks(String name, double[] values, + ConditionOptionCall opLambda) { + PercentileRanksAggregationBuilder builder = regPercentileRanksA(name, "createdTime", values); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Histogram() { + setCreatedTime_Histogram(null); + } + + public void setCreatedTime_Histogram(ConditionOptionCall opLambda) { + setCreatedTime_Histogram("createdTime", opLambda, null); + } + + public void setCreatedTime_Histogram(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedTime_Histogram("createdTime", opLambda, aggsLambda); + } + + public void setCreatedTime_Histogram(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + HistogramAggregationBuilder builder = regHistogramA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedTime_Range() { + setCreatedTime_Range(null); + } + + public void setCreatedTime_Range(ConditionOptionCall opLambda) { + setCreatedTime_Range("createdTime", opLambda, null); + } + + public void setCreatedTime_Range(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedTime_Range("createdTime", opLambda, aggsLambda); + } + + public void setCreatedTime_Range(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + RangeAggregationBuilder builder = regRangeA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setCreatedTime_Count() { + setCreatedTime_Count(null); + } + + public void setCreatedTime_Count(ConditionOptionCall opLambda) { + setCreatedTime_Count("createdTime", opLambda); + } + + public void setCreatedTime_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Cardinality() { + setCreatedTime_Cardinality(null); + } + + public void setCreatedTime_Cardinality(ConditionOptionCall opLambda) { + setCreatedTime_Cardinality("createdTime", opLambda); + } + + public void setCreatedTime_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Missing() { + setCreatedTime_Missing(null); + } + + public void setCreatedTime_Missing(ConditionOptionCall opLambda) { + setCreatedTime_Missing("createdTime", opLambda, null); + } + + public void setCreatedTime_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setCreatedTime_Missing("createdTime", opLambda, aggsLambda); + } + + public void setCreatedTime_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setName_Terms() { + setName_Terms(null); + } + + public void setName_Terms(ConditionOptionCall opLambda) { + setName_Terms("name", opLambda, null); + } + + public void setName_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setName_Terms("name", opLambda, aggsLambda); + } + + public void setName_Terms(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setName_SignificantTerms() { + setName_SignificantTerms(null); + } + + public void setName_SignificantTerms(ConditionOptionCall opLambda) { + setName_SignificantTerms("name", opLambda, null); + } + + public void setName_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setName_SignificantTerms("name", opLambda, aggsLambda); + } + + public void setName_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setName_IpRange() { + setName_IpRange(null); + } + + public void setName_IpRange(ConditionOptionCall opLambda) { + setName_IpRange("name", opLambda, null); + } + + public void setName_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setName_IpRange("name", opLambda, aggsLambda); + } + + public void setName_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setName_Count() { + setName_Count(null); + } + + public void setName_Count(ConditionOptionCall opLambda) { + setName_Count("name", opLambda); + } + + public void setName_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Cardinality() { + setName_Cardinality(null); + } + + public void setName_Cardinality(ConditionOptionCall opLambda) { + setName_Cardinality("name", opLambda); + } + + public void setName_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Missing() { + setName_Missing(null); + } + + public void setName_Missing(ConditionOptionCall opLambda) { + setName_Missing("name", opLambda, null); + } + + public void setName_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setName_Missing("name", opLambda, aggsLambda); + } + + public void setName_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "name"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setOwner_Terms() { + setOwner_Terms(null); + } + + public void setOwner_Terms(ConditionOptionCall opLambda) { + setOwner_Terms("owner", opLambda, null); + } + + public void setOwner_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setOwner_Terms("owner", opLambda, aggsLambda); + } + + public void setOwner_Terms(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setOwner_SignificantTerms() { + setOwner_SignificantTerms(null); + } + + public void setOwner_SignificantTerms(ConditionOptionCall opLambda) { + setOwner_SignificantTerms("owner", opLambda, null); + } + + public void setOwner_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setOwner_SignificantTerms("owner", opLambda, aggsLambda); + } + + public void setOwner_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setOwner_IpRange() { + setOwner_IpRange(null); + } + + public void setOwner_IpRange(ConditionOptionCall opLambda) { + setOwner_IpRange("owner", opLambda, null); + } + + public void setOwner_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setOwner_IpRange("owner", opLambda, aggsLambda); + } + + public void setOwner_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setOwner_Count() { + setOwner_Count(null); + } + + public void setOwner_Count(ConditionOptionCall opLambda) { + setOwner_Count("owner", opLambda); + } + + public void setOwner_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Cardinality() { + setOwner_Cardinality(null); + } + + public void setOwner_Cardinality(ConditionOptionCall opLambda) { + setOwner_Cardinality("owner", opLambda); + } + + public void setOwner_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Missing() { + setOwner_Missing(null); + } + + public void setOwner_Missing(ConditionOptionCall opLambda) { + setOwner_Missing("owner", opLambda, null); + } + + public void setOwner_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setOwner_Missing("owner", opLambda, aggsLambda); + } + + public void setOwner_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPaths_Terms() { + setPaths_Terms(null); + } + + public void setPaths_Terms(ConditionOptionCall opLambda) { + setPaths_Terms("paths", opLambda, null); + } + + public void setPaths_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPaths_Terms("paths", opLambda, aggsLambda); + } + + public void setPaths_Terms(String name, ConditionOptionCall opLambda, OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPaths_SignificantTerms() { + setPaths_SignificantTerms(null); + } + + public void setPaths_SignificantTerms(ConditionOptionCall opLambda) { + setPaths_SignificantTerms("paths", opLambda, null); + } + + public void setPaths_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setPaths_SignificantTerms("paths", opLambda, aggsLambda); + } + + public void setPaths_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPaths_IpRange() { + setPaths_IpRange(null); + } + + public void setPaths_IpRange(ConditionOptionCall opLambda) { + setPaths_IpRange("paths", opLambda, null); + } + + public void setPaths_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPaths_IpRange("paths", opLambda, aggsLambda); + } + + public void setPaths_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPaths_Count() { + setPaths_Count(null); + } + + public void setPaths_Count(ConditionOptionCall opLambda) { + setPaths_Count("paths", opLambda); + } + + public void setPaths_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Cardinality() { + setPaths_Cardinality(null); + } + + public void setPaths_Cardinality(ConditionOptionCall opLambda) { + setPaths_Cardinality("paths", opLambda); + } + + public void setPaths_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Missing() { + setPaths_Missing(null); + } + + public void setPaths_Missing(ConditionOptionCall opLambda) { + setPaths_Missing("paths", opLambda, null); + } + + public void setPaths_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPaths_Missing("paths", opLambda, aggsLambda); + } + + public void setPaths_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPermissions_Terms() { + setPermissions_Terms(null); + } + + public void setPermissions_Terms(ConditionOptionCall opLambda) { + setPermissions_Terms("permissions", opLambda, null); + } + + public void setPermissions_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPermissions_Terms("permissions", opLambda, aggsLambda); + } + + public void setPermissions_Terms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPermissions_SignificantTerms() { + setPermissions_SignificantTerms(null); + } + + public void setPermissions_SignificantTerms(ConditionOptionCall opLambda) { + setPermissions_SignificantTerms("permissions", opLambda, null); + } + + public void setPermissions_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setPermissions_SignificantTerms("permissions", opLambda, aggsLambda); + } + + public void setPermissions_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPermissions_IpRange() { + setPermissions_IpRange(null); + } + + public void setPermissions_IpRange(ConditionOptionCall opLambda) { + setPermissions_IpRange("permissions", opLambda, null); + } + + public void setPermissions_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPermissions_IpRange("permissions", opLambda, aggsLambda); + } + + public void setPermissions_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setPermissions_Count() { + setPermissions_Count(null); + } + + public void setPermissions_Count(ConditionOptionCall opLambda) { + setPermissions_Count("permissions", opLambda); + } + + public void setPermissions_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Cardinality() { + setPermissions_Cardinality(null); + } + + public void setPermissions_Cardinality(ConditionOptionCall opLambda) { + setPermissions_Cardinality("permissions", opLambda); + } + + public void setPermissions_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Missing() { + setPermissions_Missing(null); + } + + public void setPermissions_Missing(ConditionOptionCall opLambda) { + setPermissions_Missing("permissions", opLambda, null); + } + + public void setPermissions_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setPermissions_Missing("permissions", opLambda, aggsLambda); + } + + public void setPermissions_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setSortOrder_Avg() { + setSortOrder_Avg(null); + } + + public void setSortOrder_Avg(ConditionOptionCall opLambda) { + setSortOrder_Avg("sortOrder", opLambda); + } + + public void setSortOrder_Avg(String name, ConditionOptionCall opLambda) { + AvgAggregationBuilder builder = regAvgA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Max() { + setSortOrder_Max(null); + } + + public void setSortOrder_Max(ConditionOptionCall opLambda) { + setSortOrder_Max("sortOrder", opLambda); + } + + public void setSortOrder_Max(String name, ConditionOptionCall opLambda) { + MaxAggregationBuilder builder = regMaxA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Min() { + setSortOrder_Min(null); + } + + public void setSortOrder_Min(ConditionOptionCall opLambda) { + setSortOrder_Min("sortOrder", opLambda); + } + + public void setSortOrder_Min(String name, ConditionOptionCall opLambda) { + MinAggregationBuilder builder = regMinA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Sum() { + setSortOrder_Sum(null); + } + + public void setSortOrder_Sum(ConditionOptionCall opLambda) { + setSortOrder_Sum("sortOrder", opLambda); + } + + public void setSortOrder_Sum(String name, ConditionOptionCall opLambda) { + SumAggregationBuilder builder = regSumA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_ExtendedStats() { + setSortOrder_ExtendedStats(null); + } + + public void setSortOrder_ExtendedStats(ConditionOptionCall opLambda) { + setSortOrder_ExtendedStats("sortOrder", opLambda); + } + + public void setSortOrder_ExtendedStats(String name, ConditionOptionCall opLambda) { + ExtendedStatsAggregationBuilder builder = regExtendedStatsA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Stats() { + setSortOrder_Stats(null); + } + + public void setSortOrder_Stats(ConditionOptionCall opLambda) { + setSortOrder_Stats("sortOrder", opLambda); + } + + public void setSortOrder_Stats(String name, ConditionOptionCall opLambda) { + StatsAggregationBuilder builder = regStatsA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Percentiles() { + setSortOrder_Percentiles(null); + } + + public void setSortOrder_Percentiles(ConditionOptionCall opLambda) { + setSortOrder_Percentiles("sortOrder", opLambda); + } + + public void setSortOrder_Percentiles(String name, ConditionOptionCall opLambda) { + PercentilesAggregationBuilder builder = regPercentilesA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_PercentileRanks(double[] values) { + setSortOrder_PercentileRanks(values, null); + } + + public void setSortOrder_PercentileRanks(double[] values, ConditionOptionCall opLambda) { + setSortOrder_PercentileRanks("sortOrder", values, opLambda); + } + + public void setSortOrder_PercentileRanks(String name, double[] values, + ConditionOptionCall opLambda) { + PercentileRanksAggregationBuilder builder = regPercentileRanksA(name, "sortOrder", values); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Histogram() { + setSortOrder_Histogram(null); + } + + public void setSortOrder_Histogram(ConditionOptionCall opLambda) { + setSortOrder_Histogram("sortOrder", opLambda, null); + } + + public void setSortOrder_Histogram(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setSortOrder_Histogram("sortOrder", opLambda, aggsLambda); + } + + public void setSortOrder_Histogram(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + HistogramAggregationBuilder builder = regHistogramA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setSortOrder_Range() { + setSortOrder_Range(null); + } + + public void setSortOrder_Range(ConditionOptionCall opLambda) { + setSortOrder_Range("sortOrder", opLambda, null); + } + + public void setSortOrder_Range(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setSortOrder_Range("sortOrder", opLambda, aggsLambda); + } + + public void setSortOrder_Range(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + RangeAggregationBuilder builder = regRangeA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setSortOrder_Count() { + setSortOrder_Count(null); + } + + public void setSortOrder_Count(ConditionOptionCall opLambda) { + setSortOrder_Count("sortOrder", opLambda); + } + + public void setSortOrder_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Cardinality() { + setSortOrder_Cardinality(null); + } + + public void setSortOrder_Cardinality(ConditionOptionCall opLambda) { + setSortOrder_Cardinality("sortOrder", opLambda); + } + + public void setSortOrder_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Missing() { + setSortOrder_Missing(null); + } + + public void setSortOrder_Missing(ConditionOptionCall opLambda) { + setSortOrder_Missing("sortOrder", opLambda, null); + } + + public void setSortOrder_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setSortOrder_Missing("sortOrder", opLambda, aggsLambda); + } + + public void setSortOrder_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedBy_Terms() { + setUpdatedBy_Terms(null); + } + + public void setUpdatedBy_Terms(ConditionOptionCall opLambda) { + setUpdatedBy_Terms("updatedBy", opLambda, null); + } + + public void setUpdatedBy_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedBy_Terms("updatedBy", opLambda, aggsLambda); + } + + public void setUpdatedBy_Terms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedBy_SignificantTerms() { + setUpdatedBy_SignificantTerms(null); + } + + public void setUpdatedBy_SignificantTerms(ConditionOptionCall opLambda) { + setUpdatedBy_SignificantTerms("updatedBy", opLambda, null); + } + + public void setUpdatedBy_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setUpdatedBy_SignificantTerms("updatedBy", opLambda, aggsLambda); + } + + public void setUpdatedBy_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedBy_IpRange() { + setUpdatedBy_IpRange(null); + } + + public void setUpdatedBy_IpRange(ConditionOptionCall opLambda) { + setUpdatedBy_IpRange("updatedBy", opLambda, null); + } + + public void setUpdatedBy_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedBy_IpRange("updatedBy", opLambda, aggsLambda); + } + + public void setUpdatedBy_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedBy_Count() { + setUpdatedBy_Count(null); + } + + public void setUpdatedBy_Count(ConditionOptionCall opLambda) { + setUpdatedBy_Count("updatedBy", opLambda); + } + + public void setUpdatedBy_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Cardinality() { + setUpdatedBy_Cardinality(null); + } + + public void setUpdatedBy_Cardinality(ConditionOptionCall opLambda) { + setUpdatedBy_Cardinality("updatedBy", opLambda); + } + + public void setUpdatedBy_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Missing() { + setUpdatedBy_Missing(null); + } + + public void setUpdatedBy_Missing(ConditionOptionCall opLambda) { + setUpdatedBy_Missing("updatedBy", opLambda, null); + } + + public void setUpdatedBy_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedBy_Missing("updatedBy", opLambda, aggsLambda); + } + + public void setUpdatedBy_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedTime_Avg() { + setUpdatedTime_Avg(null); + } + + public void setUpdatedTime_Avg(ConditionOptionCall opLambda) { + setUpdatedTime_Avg("updatedTime", opLambda); + } + + public void setUpdatedTime_Avg(String name, ConditionOptionCall opLambda) { + AvgAggregationBuilder builder = regAvgA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Max() { + setUpdatedTime_Max(null); + } + + public void setUpdatedTime_Max(ConditionOptionCall opLambda) { + setUpdatedTime_Max("updatedTime", opLambda); + } + + public void setUpdatedTime_Max(String name, ConditionOptionCall opLambda) { + MaxAggregationBuilder builder = regMaxA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Min() { + setUpdatedTime_Min(null); + } + + public void setUpdatedTime_Min(ConditionOptionCall opLambda) { + setUpdatedTime_Min("updatedTime", opLambda); + } + + public void setUpdatedTime_Min(String name, ConditionOptionCall opLambda) { + MinAggregationBuilder builder = regMinA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Sum() { + setUpdatedTime_Sum(null); + } + + public void setUpdatedTime_Sum(ConditionOptionCall opLambda) { + setUpdatedTime_Sum("updatedTime", opLambda); + } + + public void setUpdatedTime_Sum(String name, ConditionOptionCall opLambda) { + SumAggregationBuilder builder = regSumA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_ExtendedStats() { + setUpdatedTime_ExtendedStats(null); + } + + public void setUpdatedTime_ExtendedStats(ConditionOptionCall opLambda) { + setUpdatedTime_ExtendedStats("updatedTime", opLambda); + } + + public void setUpdatedTime_ExtendedStats(String name, ConditionOptionCall opLambda) { + ExtendedStatsAggregationBuilder builder = regExtendedStatsA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Stats() { + setUpdatedTime_Stats(null); + } + + public void setUpdatedTime_Stats(ConditionOptionCall opLambda) { + setUpdatedTime_Stats("updatedTime", opLambda); + } + + public void setUpdatedTime_Stats(String name, ConditionOptionCall opLambda) { + StatsAggregationBuilder builder = regStatsA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Percentiles() { + setUpdatedTime_Percentiles(null); + } + + public void setUpdatedTime_Percentiles(ConditionOptionCall opLambda) { + setUpdatedTime_Percentiles("updatedTime", opLambda); + } + + public void setUpdatedTime_Percentiles(String name, ConditionOptionCall opLambda) { + PercentilesAggregationBuilder builder = regPercentilesA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_PercentileRanks(double[] values) { + setUpdatedTime_PercentileRanks(values, null); + } + + public void setUpdatedTime_PercentileRanks(double[] values, ConditionOptionCall opLambda) { + setUpdatedTime_PercentileRanks("updatedTime", values, opLambda); + } + + public void setUpdatedTime_PercentileRanks(String name, double[] values, + ConditionOptionCall opLambda) { + PercentileRanksAggregationBuilder builder = regPercentileRanksA(name, "updatedTime", values); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Histogram() { + setUpdatedTime_Histogram(null); + } + + public void setUpdatedTime_Histogram(ConditionOptionCall opLambda) { + setUpdatedTime_Histogram("updatedTime", opLambda, null); + } + + public void setUpdatedTime_Histogram(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedTime_Histogram("updatedTime", opLambda, aggsLambda); + } + + public void setUpdatedTime_Histogram(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + HistogramAggregationBuilder builder = regHistogramA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedTime_Range() { + setUpdatedTime_Range(null); + } + + public void setUpdatedTime_Range(ConditionOptionCall opLambda) { + setUpdatedTime_Range("updatedTime", opLambda, null); + } + + public void setUpdatedTime_Range(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedTime_Range("updatedTime", opLambda, aggsLambda); + } + + public void setUpdatedTime_Range(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + RangeAggregationBuilder builder = regRangeA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setUpdatedTime_Count() { + setUpdatedTime_Count(null); + } + + public void setUpdatedTime_Count(ConditionOptionCall opLambda) { + setUpdatedTime_Count("updatedTime", opLambda); + } + + public void setUpdatedTime_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Cardinality() { + setUpdatedTime_Cardinality(null); + } + + public void setUpdatedTime_Cardinality(ConditionOptionCall opLambda) { + setUpdatedTime_Cardinality("updatedTime", opLambda); + } + + public void setUpdatedTime_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Missing() { + setUpdatedTime_Missing(null); + } + + public void setUpdatedTime_Missing(ConditionOptionCall opLambda) { + setUpdatedTime_Missing("updatedTime", opLambda, null); + } + + public void setUpdatedTime_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setUpdatedTime_Missing("updatedTime", opLambda, aggsLambda); + } + + public void setUpdatedTime_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setVirtualHost_Terms() { + setVirtualHost_Terms(null); + } + + public void setVirtualHost_Terms(ConditionOptionCall opLambda) { + setVirtualHost_Terms("virtualHost", opLambda, null); + } + + public void setVirtualHost_Terms(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setVirtualHost_Terms("virtualHost", opLambda, aggsLambda); + } + + public void setVirtualHost_Terms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + TermsAggregationBuilder builder = regTermsA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setVirtualHost_SignificantTerms() { + setVirtualHost_SignificantTerms(null); + } + + public void setVirtualHost_SignificantTerms(ConditionOptionCall opLambda) { + setVirtualHost_SignificantTerms("virtualHost", opLambda, null); + } + + public void setVirtualHost_SignificantTerms(ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + setVirtualHost_SignificantTerms("virtualHost", opLambda, aggsLambda); + } + + public void setVirtualHost_SignificantTerms(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + SignificantTermsAggregationBuilder builder = regSignificantTermsA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setVirtualHost_IpRange() { + setVirtualHost_IpRange(null); + } + + public void setVirtualHost_IpRange(ConditionOptionCall opLambda) { + setVirtualHost_IpRange("virtualHost", opLambda, null); + } + + public void setVirtualHost_IpRange(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setVirtualHost_IpRange("virtualHost", opLambda, aggsLambda); + } + + public void setVirtualHost_IpRange(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + IpRangeAggregationBuilder builder = regIpRangeA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + + public void setVirtualHost_Count() { + setVirtualHost_Count(null); + } + + public void setVirtualHost_Count(ConditionOptionCall opLambda) { + setVirtualHost_Count("virtualHost", opLambda); + } + + public void setVirtualHost_Count(String name, ConditionOptionCall opLambda) { + ValueCountAggregationBuilder builder = regCountA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Cardinality() { + setVirtualHost_Cardinality(null); + } + + public void setVirtualHost_Cardinality(ConditionOptionCall opLambda) { + setVirtualHost_Cardinality("virtualHost", opLambda); + } + + public void setVirtualHost_Cardinality(String name, ConditionOptionCall opLambda) { + CardinalityAggregationBuilder builder = regCardinalityA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Missing() { + setVirtualHost_Missing(null); + } + + public void setVirtualHost_Missing(ConditionOptionCall opLambda) { + setVirtualHost_Missing("virtualHost", opLambda, null); + } + + public void setVirtualHost_Missing(ConditionOptionCall opLambda, OperatorCall aggsLambda) { + setVirtualHost_Missing("virtualHost", opLambda, aggsLambda); + } + + public void setVirtualHost_Missing(String name, ConditionOptionCall opLambda, + OperatorCall aggsLambda) { + MissingAggregationBuilder builder = regMissingA(name, "virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + if (aggsLambda != null) { + TagTypeCA ca = new TagTypeCA(); + aggsLambda.callback(ca); + ca.getAggregationBuilderList().forEach(builder::subAggregation); + } + } + +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/TagTypeCQ.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/TagTypeCQ.java new file mode 100644 index 0000000000..677c3df0e9 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/TagTypeCQ.java @@ -0,0 +1,24 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean.cq; + +import org.codelibs.fess.opensearch.config.cbean.cq.bs.BsTagTypeCQ; + +/** + * @author ESFlute (using FreeGen) + */ +public class TagTypeCQ extends BsTagTypeCQ { +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsTagTypeCQ.java b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsTagTypeCQ.java new file mode 100644 index 0000000000..2b1ffbcf78 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/cbean/cq/bs/BsTagTypeCQ.java @@ -0,0 +1,2297 @@ +/* + * Copyright 2012-2025 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.opensearch.config.cbean.cq.bs; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; + +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractConditionQuery; +import org.codelibs.fess.opensearch.config.cbean.cq.TagTypeCQ; +import org.dbflute.cbean.ckey.ConditionKey; +import org.codelibs.fesen.opensearch.index.query.BoolQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.CommonTermsQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.ExistsQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.IdsQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.MatchPhrasePrefixQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.MatchPhraseQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.MatchQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.PrefixQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.RangeQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.RegexpQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.SpanTermQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.TermQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.TermsQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.WildcardQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.functionscore.FunctionScoreQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.functionscore.FunctionScoreQueryBuilder.FilterFunctionBuilder; + +/** + * @author ESFlute (using FreeGen) + */ +public abstract class BsTagTypeCQ extends EsAbstractConditionQuery { + + protected static final Class suppressUnusedImportLocalDateTime = LocalDateTime.class; + + // =================================================================================== + // Name Override + // ============= + @Override + public String asTableDbName() { + return "tag_type"; + } + + @Override + public String xgetAliasName() { + return "tag_type"; + } + + // =================================================================================== + // Query Control + // ============= + public void functionScore(OperatorCall queryLambda, ScoreFunctionCall> functionsLambda, + final ConditionOptionCall opLambda) { + TagTypeCQ cq = new TagTypeCQ(); + queryLambda.callback(cq); + final Collection list = new ArrayList<>(); + if (functionsLambda != null) { + functionsLambda.callback((cqLambda, scoreFunctionBuilder) -> { + TagTypeCQ cf = new TagTypeCQ(); + cqLambda.callback(cf); + list.add(new FilterFunctionBuilder(cf.getQuery(), scoreFunctionBuilder)); + }); + } + final FunctionScoreQueryBuilder builder = regFunctionScoreQ(cq.getQuery(), list); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void filtered(FilteredCall filteredLambda) { + filtered(filteredLambda, null); + } + + public void filtered(FilteredCall filteredLambda, ConditionOptionCall opLambda) { + bool((must, should, mustNot, filter) -> { + filteredLambda.callback(must, filter); + }, opLambda); + } + + public void not(OperatorCall notLambda) { + not(notLambda, null); + } + + public void not(final OperatorCall notLambda, final ConditionOptionCall opLambda) { + bool((must, should, mustNot, filter) -> notLambda.callback(mustNot), opLambda); + } + + public void bool(BoolCall boolLambda) { + bool(boolLambda, null); + } + + public void bool(BoolCall boolLambda, ConditionOptionCall opLambda) { + TagTypeCQ mustQuery = new TagTypeCQ(); + TagTypeCQ shouldQuery = new TagTypeCQ(); + TagTypeCQ mustNotQuery = new TagTypeCQ(); + TagTypeCQ filterQuery = new TagTypeCQ(); + boolLambda.callback(mustQuery, shouldQuery, mustNotQuery, filterQuery); + if (mustQuery.hasQueries() || shouldQuery.hasQueries() || mustNotQuery.hasQueries() || filterQuery.hasQueries()) { + BoolQueryBuilder builder = regBoolCQ(mustQuery.getQueryBuilderList(), shouldQuery.getQueryBuilderList(), + mustNotQuery.getQueryBuilderList(), filterQuery.getQueryBuilderList()); + if (opLambda != null) { + opLambda.callback(builder); + } + } + } + + // =================================================================================== + // Query Set + // ========= + public void setId_Equal(String id) { + setId_Term(id, null); + } + + public void setId_Equal(String id, ConditionOptionCall opLambda) { + setId_Term(id, opLambda); + } + + public void setId_Term(String id) { + setId_Term(id, null); + } + + public void setId_Term(String id, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("_id", id); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setId_NotEqual(String id) { + setId_NotTerm(id, null); + } + + public void setId_NotTerm(String id) { + setId_NotTerm(id, null); + } + + public void setId_NotEqual(String id, ConditionOptionCall opLambda) { + setId_NotTerm(id, opLambda); + } + + public void setId_NotTerm(String id, ConditionOptionCall opLambda) { + not(not -> not.setId_Term(id), opLambda); + } + + public void setId_Terms(Collection idList) { + setId_Terms(idList, null); + } + + public void setId_Terms(Collection idList, ConditionOptionCall opLambda) { + IdsQueryBuilder builder = regIdsQ(idList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setId_InScope(Collection idList) { + setId_Terms(idList, null); + } + + public void setId_InScope(Collection idList, ConditionOptionCall opLambda) { + setId_Terms(idList, opLambda); + } + + @Deprecated + public BsTagTypeCQ addOrderBy_Id_Asc() { + regOBA("_id"); + return this; + } + + @Deprecated + public BsTagTypeCQ addOrderBy_Id_Desc() { + regOBD("_id"); + return this; + } + + public void setCreatedBy_Equal(String createdBy) { + setCreatedBy_Term(createdBy, null); + } + + public void setCreatedBy_Equal(String createdBy, ConditionOptionCall opLambda) { + setCreatedBy_Term(createdBy, opLambda); + } + + public void setCreatedBy_Term(String createdBy) { + setCreatedBy_Term(createdBy, null); + } + + public void setCreatedBy_Term(String createdBy, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_NotEqual(String createdBy) { + setCreatedBy_NotTerm(createdBy, null); + } + + public void setCreatedBy_NotTerm(String createdBy) { + setCreatedBy_NotTerm(createdBy, null); + } + + public void setCreatedBy_NotEqual(String createdBy, ConditionOptionCall opLambda) { + setCreatedBy_NotTerm(createdBy, opLambda); + } + + public void setCreatedBy_NotTerm(String createdBy, ConditionOptionCall opLambda) { + not(not -> not.setCreatedBy_Term(createdBy), opLambda); + } + + public void setCreatedBy_Terms(Collection createdByList) { + setCreatedBy_Terms(createdByList, null); + } + + public void setCreatedBy_Terms(Collection createdByList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("createdBy", createdByList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_InScope(Collection createdByList) { + setCreatedBy_Terms(createdByList, null); + } + + public void setCreatedBy_InScope(Collection createdByList, ConditionOptionCall opLambda) { + setCreatedBy_Terms(createdByList, opLambda); + } + + public void setCreatedBy_Match(String createdBy) { + setCreatedBy_Match(createdBy, null); + } + + public void setCreatedBy_Match(String createdBy, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_MatchPhrase(String createdBy) { + setCreatedBy_MatchPhrase(createdBy, null); + } + + public void setCreatedBy_MatchPhrase(String createdBy, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_MatchPhrasePrefix(String createdBy) { + setCreatedBy_MatchPhrasePrefix(createdBy, null); + } + + public void setCreatedBy_MatchPhrasePrefix(String createdBy, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Fuzzy(String createdBy) { + setCreatedBy_Fuzzy(createdBy, null); + } + + public void setCreatedBy_Fuzzy(String createdBy, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Prefix(String createdBy) { + setCreatedBy_Prefix(createdBy, null); + } + + public void setCreatedBy_Prefix(String createdBy, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Wildcard(String createdBy) { + setCreatedBy_Wildcard(createdBy, null); + } + + public void setCreatedBy_Wildcard(String createdBy, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Regexp(String createdBy) { + setCreatedBy_Regexp(createdBy, null); + } + + public void setCreatedBy_Regexp(String createdBy, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_SpanTerm(String createdBy) { + setCreatedBy_SpanTerm("createdBy", null); + } + + public void setCreatedBy_SpanTerm(String createdBy, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_GreaterThan(String createdBy) { + setCreatedBy_GreaterThan(createdBy, null); + } + + public void setCreatedBy_GreaterThan(String createdBy, ConditionOptionCall opLambda) { + final Object _value = createdBy; + RangeQueryBuilder builder = regRangeQ("createdBy", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_LessThan(String createdBy) { + setCreatedBy_LessThan(createdBy, null); + } + + public void setCreatedBy_LessThan(String createdBy, ConditionOptionCall opLambda) { + final Object _value = createdBy; + RangeQueryBuilder builder = regRangeQ("createdBy", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_GreaterEqual(String createdBy) { + setCreatedBy_GreaterEqual(createdBy, null); + } + + public void setCreatedBy_GreaterEqual(String createdBy, ConditionOptionCall opLambda) { + final Object _value = createdBy; + RangeQueryBuilder builder = regRangeQ("createdBy", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_LessEqual(String createdBy) { + setCreatedBy_LessEqual(createdBy, null); + } + + public void setCreatedBy_LessEqual(String createdBy, ConditionOptionCall opLambda) { + final Object _value = createdBy; + RangeQueryBuilder builder = regRangeQ("createdBy", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedBy_Exists() { + setCreatedBy_Exists(null); + } + + public void setCreatedBy_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("createdBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setCreatedBy_CommonTerms(String createdBy) { + setCreatedBy_CommonTerms(createdBy, null); + } + + @Deprecated + public void setCreatedBy_CommonTerms(String createdBy, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("createdBy", createdBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_CreatedBy_Asc() { + regOBA("createdBy"); + return this; + } + + public BsTagTypeCQ addOrderBy_CreatedBy_Desc() { + regOBD("createdBy"); + return this; + } + + public void setCreatedTime_Equal(Long createdTime) { + setCreatedTime_Term(createdTime, null); + } + + public void setCreatedTime_Equal(Long createdTime, ConditionOptionCall opLambda) { + setCreatedTime_Term(createdTime, opLambda); + } + + public void setCreatedTime_Term(Long createdTime) { + setCreatedTime_Term(createdTime, null); + } + + public void setCreatedTime_Term(Long createdTime, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_NotEqual(Long createdTime) { + setCreatedTime_NotTerm(createdTime, null); + } + + public void setCreatedTime_NotTerm(Long createdTime) { + setCreatedTime_NotTerm(createdTime, null); + } + + public void setCreatedTime_NotEqual(Long createdTime, ConditionOptionCall opLambda) { + setCreatedTime_NotTerm(createdTime, opLambda); + } + + public void setCreatedTime_NotTerm(Long createdTime, ConditionOptionCall opLambda) { + not(not -> not.setCreatedTime_Term(createdTime), opLambda); + } + + public void setCreatedTime_Terms(Collection createdTimeList) { + setCreatedTime_Terms(createdTimeList, null); + } + + public void setCreatedTime_Terms(Collection createdTimeList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("createdTime", createdTimeList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_InScope(Collection createdTimeList) { + setCreatedTime_Terms(createdTimeList, null); + } + + public void setCreatedTime_InScope(Collection createdTimeList, ConditionOptionCall opLambda) { + setCreatedTime_Terms(createdTimeList, opLambda); + } + + public void setCreatedTime_Match(Long createdTime) { + setCreatedTime_Match(createdTime, null); + } + + public void setCreatedTime_Match(Long createdTime, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_MatchPhrase(Long createdTime) { + setCreatedTime_MatchPhrase(createdTime, null); + } + + public void setCreatedTime_MatchPhrase(Long createdTime, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_MatchPhrasePrefix(Long createdTime) { + setCreatedTime_MatchPhrasePrefix(createdTime, null); + } + + public void setCreatedTime_MatchPhrasePrefix(Long createdTime, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Fuzzy(Long createdTime) { + setCreatedTime_Fuzzy(createdTime, null); + } + + public void setCreatedTime_Fuzzy(Long createdTime, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_GreaterThan(Long createdTime) { + setCreatedTime_GreaterThan(createdTime, null); + } + + public void setCreatedTime_GreaterThan(Long createdTime, ConditionOptionCall opLambda) { + final Object _value = createdTime; + RangeQueryBuilder builder = regRangeQ("createdTime", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_LessThan(Long createdTime) { + setCreatedTime_LessThan(createdTime, null); + } + + public void setCreatedTime_LessThan(Long createdTime, ConditionOptionCall opLambda) { + final Object _value = createdTime; + RangeQueryBuilder builder = regRangeQ("createdTime", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_GreaterEqual(Long createdTime) { + setCreatedTime_GreaterEqual(createdTime, null); + } + + public void setCreatedTime_GreaterEqual(Long createdTime, ConditionOptionCall opLambda) { + final Object _value = createdTime; + RangeQueryBuilder builder = regRangeQ("createdTime", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_LessEqual(Long createdTime) { + setCreatedTime_LessEqual(createdTime, null); + } + + public void setCreatedTime_LessEqual(Long createdTime, ConditionOptionCall opLambda) { + final Object _value = createdTime; + RangeQueryBuilder builder = regRangeQ("createdTime", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setCreatedTime_Exists() { + setCreatedTime_Exists(null); + } + + public void setCreatedTime_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("createdTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setCreatedTime_CommonTerms(Long createdTime) { + setCreatedTime_CommonTerms(createdTime, null); + } + + @Deprecated + public void setCreatedTime_CommonTerms(Long createdTime, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("createdTime", createdTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_CreatedTime_Asc() { + regOBA("createdTime"); + return this; + } + + public BsTagTypeCQ addOrderBy_CreatedTime_Desc() { + regOBD("createdTime"); + return this; + } + + public void setName_Equal(String name) { + setName_Term(name, null); + } + + public void setName_Equal(String name, ConditionOptionCall opLambda) { + setName_Term(name, opLambda); + } + + public void setName_Term(String name) { + setName_Term(name, null); + } + + public void setName_Term(String name, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_NotEqual(String name) { + setName_NotTerm(name, null); + } + + public void setName_NotTerm(String name) { + setName_NotTerm(name, null); + } + + public void setName_NotEqual(String name, ConditionOptionCall opLambda) { + setName_NotTerm(name, opLambda); + } + + public void setName_NotTerm(String name, ConditionOptionCall opLambda) { + not(not -> not.setName_Term(name), opLambda); + } + + public void setName_Terms(Collection nameList) { + setName_Terms(nameList, null); + } + + public void setName_Terms(Collection nameList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("name", nameList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_InScope(Collection nameList) { + setName_Terms(nameList, null); + } + + public void setName_InScope(Collection nameList, ConditionOptionCall opLambda) { + setName_Terms(nameList, opLambda); + } + + public void setName_Match(String name) { + setName_Match(name, null); + } + + public void setName_Match(String name, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_MatchPhrase(String name) { + setName_MatchPhrase(name, null); + } + + public void setName_MatchPhrase(String name, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_MatchPhrasePrefix(String name) { + setName_MatchPhrasePrefix(name, null); + } + + public void setName_MatchPhrasePrefix(String name, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Fuzzy(String name) { + setName_Fuzzy(name, null); + } + + public void setName_Fuzzy(String name, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Prefix(String name) { + setName_Prefix(name, null); + } + + public void setName_Prefix(String name, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Wildcard(String name) { + setName_Wildcard(name, null); + } + + public void setName_Wildcard(String name, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Regexp(String name) { + setName_Regexp(name, null); + } + + public void setName_Regexp(String name, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_SpanTerm(String name) { + setName_SpanTerm("name", null); + } + + public void setName_SpanTerm(String name, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_GreaterThan(String name) { + setName_GreaterThan(name, null); + } + + public void setName_GreaterThan(String name, ConditionOptionCall opLambda) { + final Object _value = name; + RangeQueryBuilder builder = regRangeQ("name", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_LessThan(String name) { + setName_LessThan(name, null); + } + + public void setName_LessThan(String name, ConditionOptionCall opLambda) { + final Object _value = name; + RangeQueryBuilder builder = regRangeQ("name", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_GreaterEqual(String name) { + setName_GreaterEqual(name, null); + } + + public void setName_GreaterEqual(String name, ConditionOptionCall opLambda) { + final Object _value = name; + RangeQueryBuilder builder = regRangeQ("name", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_LessEqual(String name) { + setName_LessEqual(name, null); + } + + public void setName_LessEqual(String name, ConditionOptionCall opLambda) { + final Object _value = name; + RangeQueryBuilder builder = regRangeQ("name", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setName_Exists() { + setName_Exists(null); + } + + public void setName_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("name"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setName_CommonTerms(String name) { + setName_CommonTerms(name, null); + } + + @Deprecated + public void setName_CommonTerms(String name, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("name", name); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_Name_Asc() { + regOBA("name"); + return this; + } + + public BsTagTypeCQ addOrderBy_Name_Desc() { + regOBD("name"); + return this; + } + + public void setOwner_Equal(String owner) { + setOwner_Term(owner, null); + } + + public void setOwner_Equal(String owner, ConditionOptionCall opLambda) { + setOwner_Term(owner, opLambda); + } + + public void setOwner_Term(String owner) { + setOwner_Term(owner, null); + } + + public void setOwner_Term(String owner, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_NotEqual(String owner) { + setOwner_NotTerm(owner, null); + } + + public void setOwner_NotTerm(String owner) { + setOwner_NotTerm(owner, null); + } + + public void setOwner_NotEqual(String owner, ConditionOptionCall opLambda) { + setOwner_NotTerm(owner, opLambda); + } + + public void setOwner_NotTerm(String owner, ConditionOptionCall opLambda) { + not(not -> not.setOwner_Term(owner), opLambda); + } + + public void setOwner_Terms(Collection ownerList) { + setOwner_Terms(ownerList, null); + } + + public void setOwner_Terms(Collection ownerList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("owner", ownerList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_InScope(Collection ownerList) { + setOwner_Terms(ownerList, null); + } + + public void setOwner_InScope(Collection ownerList, ConditionOptionCall opLambda) { + setOwner_Terms(ownerList, opLambda); + } + + public void setOwner_Match(String owner) { + setOwner_Match(owner, null); + } + + public void setOwner_Match(String owner, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_MatchPhrase(String owner) { + setOwner_MatchPhrase(owner, null); + } + + public void setOwner_MatchPhrase(String owner, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_MatchPhrasePrefix(String owner) { + setOwner_MatchPhrasePrefix(owner, null); + } + + public void setOwner_MatchPhrasePrefix(String owner, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Fuzzy(String owner) { + setOwner_Fuzzy(owner, null); + } + + public void setOwner_Fuzzy(String owner, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Prefix(String owner) { + setOwner_Prefix(owner, null); + } + + public void setOwner_Prefix(String owner, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Wildcard(String owner) { + setOwner_Wildcard(owner, null); + } + + public void setOwner_Wildcard(String owner, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Regexp(String owner) { + setOwner_Regexp(owner, null); + } + + public void setOwner_Regexp(String owner, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_SpanTerm(String owner) { + setOwner_SpanTerm("owner", null); + } + + public void setOwner_SpanTerm(String owner, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_GreaterThan(String owner) { + setOwner_GreaterThan(owner, null); + } + + public void setOwner_GreaterThan(String owner, ConditionOptionCall opLambda) { + final Object _value = owner; + RangeQueryBuilder builder = regRangeQ("owner", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_LessThan(String owner) { + setOwner_LessThan(owner, null); + } + + public void setOwner_LessThan(String owner, ConditionOptionCall opLambda) { + final Object _value = owner; + RangeQueryBuilder builder = regRangeQ("owner", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_GreaterEqual(String owner) { + setOwner_GreaterEqual(owner, null); + } + + public void setOwner_GreaterEqual(String owner, ConditionOptionCall opLambda) { + final Object _value = owner; + RangeQueryBuilder builder = regRangeQ("owner", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_LessEqual(String owner) { + setOwner_LessEqual(owner, null); + } + + public void setOwner_LessEqual(String owner, ConditionOptionCall opLambda) { + final Object _value = owner; + RangeQueryBuilder builder = regRangeQ("owner", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setOwner_Exists() { + setOwner_Exists(null); + } + + public void setOwner_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("owner"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setOwner_CommonTerms(String owner) { + setOwner_CommonTerms(owner, null); + } + + @Deprecated + public void setOwner_CommonTerms(String owner, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("owner", owner); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_Owner_Asc() { + regOBA("owner"); + return this; + } + + public BsTagTypeCQ addOrderBy_Owner_Desc() { + regOBD("owner"); + return this; + } + + public void setPaths_Equal(String paths) { + setPaths_Term(paths, null); + } + + public void setPaths_Equal(String paths, ConditionOptionCall opLambda) { + setPaths_Term(paths, opLambda); + } + + public void setPaths_Term(String paths) { + setPaths_Term(paths, null); + } + + public void setPaths_Term(String paths, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_NotEqual(String paths) { + setPaths_NotTerm(paths, null); + } + + public void setPaths_NotTerm(String paths) { + setPaths_NotTerm(paths, null); + } + + public void setPaths_NotEqual(String paths, ConditionOptionCall opLambda) { + setPaths_NotTerm(paths, opLambda); + } + + public void setPaths_NotTerm(String paths, ConditionOptionCall opLambda) { + not(not -> not.setPaths_Term(paths), opLambda); + } + + public void setPaths_Terms(Collection pathsList) { + setPaths_Terms(pathsList, null); + } + + public void setPaths_Terms(Collection pathsList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("paths", pathsList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_InScope(Collection pathsList) { + setPaths_Terms(pathsList, null); + } + + public void setPaths_InScope(Collection pathsList, ConditionOptionCall opLambda) { + setPaths_Terms(pathsList, opLambda); + } + + public void setPaths_Match(String paths) { + setPaths_Match(paths, null); + } + + public void setPaths_Match(String paths, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_MatchPhrase(String paths) { + setPaths_MatchPhrase(paths, null); + } + + public void setPaths_MatchPhrase(String paths, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_MatchPhrasePrefix(String paths) { + setPaths_MatchPhrasePrefix(paths, null); + } + + public void setPaths_MatchPhrasePrefix(String paths, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Fuzzy(String paths) { + setPaths_Fuzzy(paths, null); + } + + public void setPaths_Fuzzy(String paths, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Prefix(String paths) { + setPaths_Prefix(paths, null); + } + + public void setPaths_Prefix(String paths, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Wildcard(String paths) { + setPaths_Wildcard(paths, null); + } + + public void setPaths_Wildcard(String paths, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Regexp(String paths) { + setPaths_Regexp(paths, null); + } + + public void setPaths_Regexp(String paths, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_SpanTerm(String paths) { + setPaths_SpanTerm("paths", null); + } + + public void setPaths_SpanTerm(String paths, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_GreaterThan(String paths) { + setPaths_GreaterThan(paths, null); + } + + public void setPaths_GreaterThan(String paths, ConditionOptionCall opLambda) { + final Object _value = paths; + RangeQueryBuilder builder = regRangeQ("paths", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_LessThan(String paths) { + setPaths_LessThan(paths, null); + } + + public void setPaths_LessThan(String paths, ConditionOptionCall opLambda) { + final Object _value = paths; + RangeQueryBuilder builder = regRangeQ("paths", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_GreaterEqual(String paths) { + setPaths_GreaterEqual(paths, null); + } + + public void setPaths_GreaterEqual(String paths, ConditionOptionCall opLambda) { + final Object _value = paths; + RangeQueryBuilder builder = regRangeQ("paths", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_LessEqual(String paths) { + setPaths_LessEqual(paths, null); + } + + public void setPaths_LessEqual(String paths, ConditionOptionCall opLambda) { + final Object _value = paths; + RangeQueryBuilder builder = regRangeQ("paths", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPaths_Exists() { + setPaths_Exists(null); + } + + public void setPaths_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("paths"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setPaths_CommonTerms(String paths) { + setPaths_CommonTerms(paths, null); + } + + @Deprecated + public void setPaths_CommonTerms(String paths, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("paths", paths); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_Paths_Asc() { + regOBA("paths"); + return this; + } + + public BsTagTypeCQ addOrderBy_Paths_Desc() { + regOBD("paths"); + return this; + } + + public void setPermissions_Equal(String permissions) { + setPermissions_Term(permissions, null); + } + + public void setPermissions_Equal(String permissions, ConditionOptionCall opLambda) { + setPermissions_Term(permissions, opLambda); + } + + public void setPermissions_Term(String permissions) { + setPermissions_Term(permissions, null); + } + + public void setPermissions_Term(String permissions, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_NotEqual(String permissions) { + setPermissions_NotTerm(permissions, null); + } + + public void setPermissions_NotTerm(String permissions) { + setPermissions_NotTerm(permissions, null); + } + + public void setPermissions_NotEqual(String permissions, ConditionOptionCall opLambda) { + setPermissions_NotTerm(permissions, opLambda); + } + + public void setPermissions_NotTerm(String permissions, ConditionOptionCall opLambda) { + not(not -> not.setPermissions_Term(permissions), opLambda); + } + + public void setPermissions_Terms(Collection permissionsList) { + setPermissions_Terms(permissionsList, null); + } + + public void setPermissions_Terms(Collection permissionsList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("permissions", permissionsList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_InScope(Collection permissionsList) { + setPermissions_Terms(permissionsList, null); + } + + public void setPermissions_InScope(Collection permissionsList, ConditionOptionCall opLambda) { + setPermissions_Terms(permissionsList, opLambda); + } + + public void setPermissions_Match(String permissions) { + setPermissions_Match(permissions, null); + } + + public void setPermissions_Match(String permissions, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_MatchPhrase(String permissions) { + setPermissions_MatchPhrase(permissions, null); + } + + public void setPermissions_MatchPhrase(String permissions, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_MatchPhrasePrefix(String permissions) { + setPermissions_MatchPhrasePrefix(permissions, null); + } + + public void setPermissions_MatchPhrasePrefix(String permissions, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Fuzzy(String permissions) { + setPermissions_Fuzzy(permissions, null); + } + + public void setPermissions_Fuzzy(String permissions, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Prefix(String permissions) { + setPermissions_Prefix(permissions, null); + } + + public void setPermissions_Prefix(String permissions, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Wildcard(String permissions) { + setPermissions_Wildcard(permissions, null); + } + + public void setPermissions_Wildcard(String permissions, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Regexp(String permissions) { + setPermissions_Regexp(permissions, null); + } + + public void setPermissions_Regexp(String permissions, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_SpanTerm(String permissions) { + setPermissions_SpanTerm("permissions", null); + } + + public void setPermissions_SpanTerm(String permissions, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_GreaterThan(String permissions) { + setPermissions_GreaterThan(permissions, null); + } + + public void setPermissions_GreaterThan(String permissions, ConditionOptionCall opLambda) { + final Object _value = permissions; + RangeQueryBuilder builder = regRangeQ("permissions", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_LessThan(String permissions) { + setPermissions_LessThan(permissions, null); + } + + public void setPermissions_LessThan(String permissions, ConditionOptionCall opLambda) { + final Object _value = permissions; + RangeQueryBuilder builder = regRangeQ("permissions", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_GreaterEqual(String permissions) { + setPermissions_GreaterEqual(permissions, null); + } + + public void setPermissions_GreaterEqual(String permissions, ConditionOptionCall opLambda) { + final Object _value = permissions; + RangeQueryBuilder builder = regRangeQ("permissions", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_LessEqual(String permissions) { + setPermissions_LessEqual(permissions, null); + } + + public void setPermissions_LessEqual(String permissions, ConditionOptionCall opLambda) { + final Object _value = permissions; + RangeQueryBuilder builder = regRangeQ("permissions", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setPermissions_Exists() { + setPermissions_Exists(null); + } + + public void setPermissions_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("permissions"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setPermissions_CommonTerms(String permissions) { + setPermissions_CommonTerms(permissions, null); + } + + @Deprecated + public void setPermissions_CommonTerms(String permissions, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("permissions", permissions); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_Permissions_Asc() { + regOBA("permissions"); + return this; + } + + public BsTagTypeCQ addOrderBy_Permissions_Desc() { + regOBD("permissions"); + return this; + } + + public void setSortOrder_Equal(Integer sortOrder) { + setSortOrder_Term(sortOrder, null); + } + + public void setSortOrder_Equal(Integer sortOrder, ConditionOptionCall opLambda) { + setSortOrder_Term(sortOrder, opLambda); + } + + public void setSortOrder_Term(Integer sortOrder) { + setSortOrder_Term(sortOrder, null); + } + + public void setSortOrder_Term(Integer sortOrder, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_NotEqual(Integer sortOrder) { + setSortOrder_NotTerm(sortOrder, null); + } + + public void setSortOrder_NotTerm(Integer sortOrder) { + setSortOrder_NotTerm(sortOrder, null); + } + + public void setSortOrder_NotEqual(Integer sortOrder, ConditionOptionCall opLambda) { + setSortOrder_NotTerm(sortOrder, opLambda); + } + + public void setSortOrder_NotTerm(Integer sortOrder, ConditionOptionCall opLambda) { + not(not -> not.setSortOrder_Term(sortOrder), opLambda); + } + + public void setSortOrder_Terms(Collection sortOrderList) { + setSortOrder_Terms(sortOrderList, null); + } + + public void setSortOrder_Terms(Collection sortOrderList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("sortOrder", sortOrderList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_InScope(Collection sortOrderList) { + setSortOrder_Terms(sortOrderList, null); + } + + public void setSortOrder_InScope(Collection sortOrderList, ConditionOptionCall opLambda) { + setSortOrder_Terms(sortOrderList, opLambda); + } + + public void setSortOrder_Match(Integer sortOrder) { + setSortOrder_Match(sortOrder, null); + } + + public void setSortOrder_Match(Integer sortOrder, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_MatchPhrase(Integer sortOrder) { + setSortOrder_MatchPhrase(sortOrder, null); + } + + public void setSortOrder_MatchPhrase(Integer sortOrder, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_MatchPhrasePrefix(Integer sortOrder) { + setSortOrder_MatchPhrasePrefix(sortOrder, null); + } + + public void setSortOrder_MatchPhrasePrefix(Integer sortOrder, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Fuzzy(Integer sortOrder) { + setSortOrder_Fuzzy(sortOrder, null); + } + + public void setSortOrder_Fuzzy(Integer sortOrder, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_GreaterThan(Integer sortOrder) { + setSortOrder_GreaterThan(sortOrder, null); + } + + public void setSortOrder_GreaterThan(Integer sortOrder, ConditionOptionCall opLambda) { + final Object _value = sortOrder; + RangeQueryBuilder builder = regRangeQ("sortOrder", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_LessThan(Integer sortOrder) { + setSortOrder_LessThan(sortOrder, null); + } + + public void setSortOrder_LessThan(Integer sortOrder, ConditionOptionCall opLambda) { + final Object _value = sortOrder; + RangeQueryBuilder builder = regRangeQ("sortOrder", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_GreaterEqual(Integer sortOrder) { + setSortOrder_GreaterEqual(sortOrder, null); + } + + public void setSortOrder_GreaterEqual(Integer sortOrder, ConditionOptionCall opLambda) { + final Object _value = sortOrder; + RangeQueryBuilder builder = regRangeQ("sortOrder", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_LessEqual(Integer sortOrder) { + setSortOrder_LessEqual(sortOrder, null); + } + + public void setSortOrder_LessEqual(Integer sortOrder, ConditionOptionCall opLambda) { + final Object _value = sortOrder; + RangeQueryBuilder builder = regRangeQ("sortOrder", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setSortOrder_Exists() { + setSortOrder_Exists(null); + } + + public void setSortOrder_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("sortOrder"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setSortOrder_CommonTerms(Integer sortOrder) { + setSortOrder_CommonTerms(sortOrder, null); + } + + @Deprecated + public void setSortOrder_CommonTerms(Integer sortOrder, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("sortOrder", sortOrder); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_SortOrder_Asc() { + regOBA("sortOrder"); + return this; + } + + public BsTagTypeCQ addOrderBy_SortOrder_Desc() { + regOBD("sortOrder"); + return this; + } + + public void setUpdatedBy_Equal(String updatedBy) { + setUpdatedBy_Term(updatedBy, null); + } + + public void setUpdatedBy_Equal(String updatedBy, ConditionOptionCall opLambda) { + setUpdatedBy_Term(updatedBy, opLambda); + } + + public void setUpdatedBy_Term(String updatedBy) { + setUpdatedBy_Term(updatedBy, null); + } + + public void setUpdatedBy_Term(String updatedBy, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_NotEqual(String updatedBy) { + setUpdatedBy_NotTerm(updatedBy, null); + } + + public void setUpdatedBy_NotTerm(String updatedBy) { + setUpdatedBy_NotTerm(updatedBy, null); + } + + public void setUpdatedBy_NotEqual(String updatedBy, ConditionOptionCall opLambda) { + setUpdatedBy_NotTerm(updatedBy, opLambda); + } + + public void setUpdatedBy_NotTerm(String updatedBy, ConditionOptionCall opLambda) { + not(not -> not.setUpdatedBy_Term(updatedBy), opLambda); + } + + public void setUpdatedBy_Terms(Collection updatedByList) { + setUpdatedBy_Terms(updatedByList, null); + } + + public void setUpdatedBy_Terms(Collection updatedByList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("updatedBy", updatedByList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_InScope(Collection updatedByList) { + setUpdatedBy_Terms(updatedByList, null); + } + + public void setUpdatedBy_InScope(Collection updatedByList, ConditionOptionCall opLambda) { + setUpdatedBy_Terms(updatedByList, opLambda); + } + + public void setUpdatedBy_Match(String updatedBy) { + setUpdatedBy_Match(updatedBy, null); + } + + public void setUpdatedBy_Match(String updatedBy, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_MatchPhrase(String updatedBy) { + setUpdatedBy_MatchPhrase(updatedBy, null); + } + + public void setUpdatedBy_MatchPhrase(String updatedBy, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_MatchPhrasePrefix(String updatedBy) { + setUpdatedBy_MatchPhrasePrefix(updatedBy, null); + } + + public void setUpdatedBy_MatchPhrasePrefix(String updatedBy, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Fuzzy(String updatedBy) { + setUpdatedBy_Fuzzy(updatedBy, null); + } + + public void setUpdatedBy_Fuzzy(String updatedBy, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Prefix(String updatedBy) { + setUpdatedBy_Prefix(updatedBy, null); + } + + public void setUpdatedBy_Prefix(String updatedBy, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Wildcard(String updatedBy) { + setUpdatedBy_Wildcard(updatedBy, null); + } + + public void setUpdatedBy_Wildcard(String updatedBy, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Regexp(String updatedBy) { + setUpdatedBy_Regexp(updatedBy, null); + } + + public void setUpdatedBy_Regexp(String updatedBy, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_SpanTerm(String updatedBy) { + setUpdatedBy_SpanTerm("updatedBy", null); + } + + public void setUpdatedBy_SpanTerm(String updatedBy, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_GreaterThan(String updatedBy) { + setUpdatedBy_GreaterThan(updatedBy, null); + } + + public void setUpdatedBy_GreaterThan(String updatedBy, ConditionOptionCall opLambda) { + final Object _value = updatedBy; + RangeQueryBuilder builder = regRangeQ("updatedBy", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_LessThan(String updatedBy) { + setUpdatedBy_LessThan(updatedBy, null); + } + + public void setUpdatedBy_LessThan(String updatedBy, ConditionOptionCall opLambda) { + final Object _value = updatedBy; + RangeQueryBuilder builder = regRangeQ("updatedBy", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_GreaterEqual(String updatedBy) { + setUpdatedBy_GreaterEqual(updatedBy, null); + } + + public void setUpdatedBy_GreaterEqual(String updatedBy, ConditionOptionCall opLambda) { + final Object _value = updatedBy; + RangeQueryBuilder builder = regRangeQ("updatedBy", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_LessEqual(String updatedBy) { + setUpdatedBy_LessEqual(updatedBy, null); + } + + public void setUpdatedBy_LessEqual(String updatedBy, ConditionOptionCall opLambda) { + final Object _value = updatedBy; + RangeQueryBuilder builder = regRangeQ("updatedBy", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedBy_Exists() { + setUpdatedBy_Exists(null); + } + + public void setUpdatedBy_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("updatedBy"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setUpdatedBy_CommonTerms(String updatedBy) { + setUpdatedBy_CommonTerms(updatedBy, null); + } + + @Deprecated + public void setUpdatedBy_CommonTerms(String updatedBy, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("updatedBy", updatedBy); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_UpdatedBy_Asc() { + regOBA("updatedBy"); + return this; + } + + public BsTagTypeCQ addOrderBy_UpdatedBy_Desc() { + regOBD("updatedBy"); + return this; + } + + public void setUpdatedTime_Equal(Long updatedTime) { + setUpdatedTime_Term(updatedTime, null); + } + + public void setUpdatedTime_Equal(Long updatedTime, ConditionOptionCall opLambda) { + setUpdatedTime_Term(updatedTime, opLambda); + } + + public void setUpdatedTime_Term(Long updatedTime) { + setUpdatedTime_Term(updatedTime, null); + } + + public void setUpdatedTime_Term(Long updatedTime, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_NotEqual(Long updatedTime) { + setUpdatedTime_NotTerm(updatedTime, null); + } + + public void setUpdatedTime_NotTerm(Long updatedTime) { + setUpdatedTime_NotTerm(updatedTime, null); + } + + public void setUpdatedTime_NotEqual(Long updatedTime, ConditionOptionCall opLambda) { + setUpdatedTime_NotTerm(updatedTime, opLambda); + } + + public void setUpdatedTime_NotTerm(Long updatedTime, ConditionOptionCall opLambda) { + not(not -> not.setUpdatedTime_Term(updatedTime), opLambda); + } + + public void setUpdatedTime_Terms(Collection updatedTimeList) { + setUpdatedTime_Terms(updatedTimeList, null); + } + + public void setUpdatedTime_Terms(Collection updatedTimeList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("updatedTime", updatedTimeList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_InScope(Collection updatedTimeList) { + setUpdatedTime_Terms(updatedTimeList, null); + } + + public void setUpdatedTime_InScope(Collection updatedTimeList, ConditionOptionCall opLambda) { + setUpdatedTime_Terms(updatedTimeList, opLambda); + } + + public void setUpdatedTime_Match(Long updatedTime) { + setUpdatedTime_Match(updatedTime, null); + } + + public void setUpdatedTime_Match(Long updatedTime, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_MatchPhrase(Long updatedTime) { + setUpdatedTime_MatchPhrase(updatedTime, null); + } + + public void setUpdatedTime_MatchPhrase(Long updatedTime, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_MatchPhrasePrefix(Long updatedTime) { + setUpdatedTime_MatchPhrasePrefix(updatedTime, null); + } + + public void setUpdatedTime_MatchPhrasePrefix(Long updatedTime, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Fuzzy(Long updatedTime) { + setUpdatedTime_Fuzzy(updatedTime, null); + } + + public void setUpdatedTime_Fuzzy(Long updatedTime, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_GreaterThan(Long updatedTime) { + setUpdatedTime_GreaterThan(updatedTime, null); + } + + public void setUpdatedTime_GreaterThan(Long updatedTime, ConditionOptionCall opLambda) { + final Object _value = updatedTime; + RangeQueryBuilder builder = regRangeQ("updatedTime", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_LessThan(Long updatedTime) { + setUpdatedTime_LessThan(updatedTime, null); + } + + public void setUpdatedTime_LessThan(Long updatedTime, ConditionOptionCall opLambda) { + final Object _value = updatedTime; + RangeQueryBuilder builder = regRangeQ("updatedTime", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_GreaterEqual(Long updatedTime) { + setUpdatedTime_GreaterEqual(updatedTime, null); + } + + public void setUpdatedTime_GreaterEqual(Long updatedTime, ConditionOptionCall opLambda) { + final Object _value = updatedTime; + RangeQueryBuilder builder = regRangeQ("updatedTime", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_LessEqual(Long updatedTime) { + setUpdatedTime_LessEqual(updatedTime, null); + } + + public void setUpdatedTime_LessEqual(Long updatedTime, ConditionOptionCall opLambda) { + final Object _value = updatedTime; + RangeQueryBuilder builder = regRangeQ("updatedTime", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setUpdatedTime_Exists() { + setUpdatedTime_Exists(null); + } + + public void setUpdatedTime_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("updatedTime"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setUpdatedTime_CommonTerms(Long updatedTime) { + setUpdatedTime_CommonTerms(updatedTime, null); + } + + @Deprecated + public void setUpdatedTime_CommonTerms(Long updatedTime, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("updatedTime", updatedTime); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_UpdatedTime_Asc() { + regOBA("updatedTime"); + return this; + } + + public BsTagTypeCQ addOrderBy_UpdatedTime_Desc() { + regOBD("updatedTime"); + return this; + } + + public void setVirtualHost_Equal(String virtualHost) { + setVirtualHost_Term(virtualHost, null); + } + + public void setVirtualHost_Equal(String virtualHost, ConditionOptionCall opLambda) { + setVirtualHost_Term(virtualHost, opLambda); + } + + public void setVirtualHost_Term(String virtualHost) { + setVirtualHost_Term(virtualHost, null); + } + + public void setVirtualHost_Term(String virtualHost, ConditionOptionCall opLambda) { + TermQueryBuilder builder = regTermQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_NotEqual(String virtualHost) { + setVirtualHost_NotTerm(virtualHost, null); + } + + public void setVirtualHost_NotTerm(String virtualHost) { + setVirtualHost_NotTerm(virtualHost, null); + } + + public void setVirtualHost_NotEqual(String virtualHost, ConditionOptionCall opLambda) { + setVirtualHost_NotTerm(virtualHost, opLambda); + } + + public void setVirtualHost_NotTerm(String virtualHost, ConditionOptionCall opLambda) { + not(not -> not.setVirtualHost_Term(virtualHost), opLambda); + } + + public void setVirtualHost_Terms(Collection virtualHostList) { + setVirtualHost_Terms(virtualHostList, null); + } + + public void setVirtualHost_Terms(Collection virtualHostList, ConditionOptionCall opLambda) { + TermsQueryBuilder builder = regTermsQ("virtualHost", virtualHostList); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_InScope(Collection virtualHostList) { + setVirtualHost_Terms(virtualHostList, null); + } + + public void setVirtualHost_InScope(Collection virtualHostList, ConditionOptionCall opLambda) { + setVirtualHost_Terms(virtualHostList, opLambda); + } + + public void setVirtualHost_Match(String virtualHost) { + setVirtualHost_Match(virtualHost, null); + } + + public void setVirtualHost_Match(String virtualHost, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regMatchQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_MatchPhrase(String virtualHost) { + setVirtualHost_MatchPhrase(virtualHost, null); + } + + public void setVirtualHost_MatchPhrase(String virtualHost, ConditionOptionCall opLambda) { + MatchPhraseQueryBuilder builder = regMatchPhraseQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_MatchPhrasePrefix(String virtualHost) { + setVirtualHost_MatchPhrasePrefix(virtualHost, null); + } + + public void setVirtualHost_MatchPhrasePrefix(String virtualHost, ConditionOptionCall opLambda) { + MatchPhrasePrefixQueryBuilder builder = regMatchPhrasePrefixQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Fuzzy(String virtualHost) { + setVirtualHost_Fuzzy(virtualHost, null); + } + + public void setVirtualHost_Fuzzy(String virtualHost, ConditionOptionCall opLambda) { + MatchQueryBuilder builder = regFuzzyQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Prefix(String virtualHost) { + setVirtualHost_Prefix(virtualHost, null); + } + + public void setVirtualHost_Prefix(String virtualHost, ConditionOptionCall opLambda) { + PrefixQueryBuilder builder = regPrefixQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Wildcard(String virtualHost) { + setVirtualHost_Wildcard(virtualHost, null); + } + + public void setVirtualHost_Wildcard(String virtualHost, ConditionOptionCall opLambda) { + WildcardQueryBuilder builder = regWildcardQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Regexp(String virtualHost) { + setVirtualHost_Regexp(virtualHost, null); + } + + public void setVirtualHost_Regexp(String virtualHost, ConditionOptionCall opLambda) { + RegexpQueryBuilder builder = regRegexpQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_SpanTerm(String virtualHost) { + setVirtualHost_SpanTerm("virtualHost", null); + } + + public void setVirtualHost_SpanTerm(String virtualHost, ConditionOptionCall opLambda) { + SpanTermQueryBuilder builder = regSpanTermQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_GreaterThan(String virtualHost) { + setVirtualHost_GreaterThan(virtualHost, null); + } + + public void setVirtualHost_GreaterThan(String virtualHost, ConditionOptionCall opLambda) { + final Object _value = virtualHost; + RangeQueryBuilder builder = regRangeQ("virtualHost", ConditionKey.CK_GREATER_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_LessThan(String virtualHost) { + setVirtualHost_LessThan(virtualHost, null); + } + + public void setVirtualHost_LessThan(String virtualHost, ConditionOptionCall opLambda) { + final Object _value = virtualHost; + RangeQueryBuilder builder = regRangeQ("virtualHost", ConditionKey.CK_LESS_THAN, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_GreaterEqual(String virtualHost) { + setVirtualHost_GreaterEqual(virtualHost, null); + } + + public void setVirtualHost_GreaterEqual(String virtualHost, ConditionOptionCall opLambda) { + final Object _value = virtualHost; + RangeQueryBuilder builder = regRangeQ("virtualHost", ConditionKey.CK_GREATER_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_LessEqual(String virtualHost) { + setVirtualHost_LessEqual(virtualHost, null); + } + + public void setVirtualHost_LessEqual(String virtualHost, ConditionOptionCall opLambda) { + final Object _value = virtualHost; + RangeQueryBuilder builder = regRangeQ("virtualHost", ConditionKey.CK_LESS_EQUAL, _value); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public void setVirtualHost_Exists() { + setVirtualHost_Exists(null); + } + + public void setVirtualHost_Exists(ConditionOptionCall opLambda) { + ExistsQueryBuilder builder = regExistsQ("virtualHost"); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + @Deprecated + public void setVirtualHost_CommonTerms(String virtualHost) { + setVirtualHost_CommonTerms(virtualHost, null); + } + + @Deprecated + public void setVirtualHost_CommonTerms(String virtualHost, ConditionOptionCall opLambda) { + CommonTermsQueryBuilder builder = regCommonTermsQ("virtualHost", virtualHost); + if (opLambda != null) { + opLambda.callback(builder); + } + } + + public BsTagTypeCQ addOrderBy_VirtualHost_Asc() { + regOBA("virtualHost"); + return this; + } + + public BsTagTypeCQ addOrderBy_VirtualHost_Desc() { + regOBD("virtualHost"); + return this; + } + +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/exbhv/TagTypeBhv.java b/src/main/java/org/codelibs/fess/opensearch/config/exbhv/TagTypeBhv.java new file mode 100644 index 0000000000..aa03505c12 --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/exbhv/TagTypeBhv.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2025 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.opensearch.config.exbhv; + +import java.util.regex.Pattern; + +import org.codelibs.fess.opensearch.config.bsbhv.BsTagTypeBhv; +import org.codelibs.fess.util.ComponentUtil; + +/** + * @author FreeGen + */ +public class TagTypeBhv extends BsTagTypeBhv { + private String indexName = null; + + @Override + protected String asEsIndex() { + if (indexName == null) { + final String name = ComponentUtil.getFessConfig().getIndexConfigIndex(); + indexName = super.asEsIndex().replaceFirst(Pattern.quote("fess_config"), name); + } + return indexName; + } +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java b/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java new file mode 100644 index 0000000000..eb4243715d --- /dev/null +++ b/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java @@ -0,0 +1,87 @@ +/* + * Copyright 2012-2025 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.opensearch.config.exentity; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; + +import org.codelibs.fess.opensearch.config.bsentity.BsTagType; + +/** + * A tag of one user: its name, its owner and the URLs of the documents it is put on. + * + * @author FreeGen + */ +public class TagType extends BsTagType { + + private static final long serialVersionUID = 1L; + + public String getId() { + return asDocMeta().id(); + } + + public void setId(final String id) { + asDocMeta().id(id); + } + + public Long getVersionNo() { + return asDocMeta().version(); + } + + public void setVersionNo(final Long version) { + asDocMeta().version(version); + } + + public Long getSeqNo() { + return asDocMeta().seqNo(); + } + + public void setSeqNo(final Long seqNo) { + asDocMeta().seqNo(seqNo); + } + + public Long getPrimaryTerm() { + return asDocMeta().primaryTerm(); + } + + public void setPrimaryTerm(final Long primaryTerm) { + asDocMeta().primaryTerm(primaryTerm); + } + + /** + * Returns the value this tag puts on the tag field of a document: the name and the owner, each + * encoded as Base64URL without padding from UTF-8, joined by {@code ':'}. + * + * @return the tag value, or null when the name or the owner is not set + */ + public String getTagValue() { + if (name == null || owner == null) { + return null; + } + final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); + return encoder.encodeToString(name.getBytes(StandardCharsets.UTF_8)) + ":" + + encoder.encodeToString(owner.getBytes(StandardCharsets.UTF_8)); + } + + @Override + public String toString() { + return "TagType [createdBy=" + createdBy + ", createdTime=" + createdTime + ", name=" + name + ", owner=" + owner + ", paths=" + + (paths == null ? null : paths.length) + ", permissions=" + Arrays.toString(permissions) + ", sortOrder=" + sortOrder + + ", updatedBy=" + updatedBy + ", updatedTime=" + updatedTime + ", virtualHost=" + virtualHost + ", docMeta=" + docMeta + + "]"; + } +} diff --git a/src/main/resources/esclient.xml b/src/main/resources/esclient.xml index da999ff4e9..550fd7e9d6 100644 --- a/src/main/resources/esclient.xml +++ b/src/main/resources/esclient.xml @@ -488,6 +488,9 @@ "fess_config.scheduled_job/scheduled_job" + + "fess_config.tag_type/tag_type" + "fess_config.thumbnail_queue/thumbnail_queue" diff --git a/src/main/resources/esflute_config.xml b/src/main/resources/esflute_config.xml index a601552481..12a2c3b2d7 100644 --- a/src/main/resources/esflute_config.xml +++ b/src/main/resources/esflute_config.xml @@ -30,6 +30,7 @@ + diff --git a/src/main/resources/fess_config.properties b/src/main/resources/fess_config.properties index 099c43af6c..45ea9f983b 100644 --- a/src/main/resources/fess_config.properties +++ b/src/main/resources/fess_config.properties @@ -585,6 +585,8 @@ index.field.created=created index.field.timestamp=timestamp # Field name for label in the index. index.field.label=label +# Field name for the user tags of the document in the index. +index.field.tag=tag # Field name for MIME type in the index. index.field.mimetype=mimetype # Field name for parent ID in the index. @@ -1129,6 +1131,20 @@ logging.chat.max.queue.size=10000 search.history.enabled=true # Maximum number of search history entries returned per user. search.history.size=10 +# Whether logged-in users can tag documents. Each tag belongs to the user who created it. +user.tag.enabled=false +# Maximum length of a tag name, in code points. +user.tag.name.max.length=50 +# Maximum number of tags one user can own. +user.tag.max.tags=1000 +# Maximum number of URLs one tag can be put on. +user.tag.max.paths=10000 +# Maximum number of pending tag changes held in memory until they are applied to the documents. +user.tag.queue.max.size=10000 +# Number of URLs updated per bulk request when tag changes are applied to the documents. +user.tag.process.batch.size=100 +# Maximum number of tags visible to one user in a search. +user.tag.visible.max.size=1000 # ======================================================================================== # Web @@ -1246,6 +1262,8 @@ page.docboost.max.fetch.size=1000 page.keymatch.max.fetch.size=1000 # Maximum number of labeltype records to fetch per page. page.labeltype.max.fetch.size=1000 +# Maximum number of tagtype records to fetch per page. +page.tagtype.max.fetch.size=1000 # Maximum number of roletype records to fetch per page. page.roletype.max.fetch.size=1000 # Maximum number of user records to fetch per page. @@ -1506,6 +1524,8 @@ online.help.name.fileconfig=fileconfig online.help.name.fileauth=fileauth # Online help key for label type. online.help.name.labeltype=labeltype +# Online help key for tag type. +online.help.name.tagtype=tagtype # Online help key for duplicate host. online.help.name.duplicatehost=duplicatehost # Online help key for scheduler. @@ -1829,7 +1849,7 @@ rag.chat.response.language=browser # Index Export index.export.path=/var/lib/fess/export # Comma-separated document fields omitted from files written by the index export job. -index.export.exclude.fields=cache +index.export.exclude.fields=cache,tag # Number of documents fetched per scroll request by the index export job. index.export.scroll.size=100 # Output format for exported documents; only html and json are accepted, anything else fails the job. diff --git a/src/main/resources/fess_indices/_aws/fess/doc.json b/src/main/resources/fess_indices/_aws/fess/doc.json index 485f0749f8..7143f68c0b 100644 --- a/src/main/resources/fess_indices/_aws/fess/doc.json +++ b/src/main/resources/fess_indices/_aws/fess/doc.json @@ -596,6 +596,9 @@ "label": { "type": "keyword" }, + "tag": { + "type": "keyword" + }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/_cloud/fess/doc.json b/src/main/resources/fess_indices/_cloud/fess/doc.json index 485f0749f8..7143f68c0b 100644 --- a/src/main/resources/fess_indices/_cloud/fess/doc.json +++ b/src/main/resources/fess_indices/_cloud/fess/doc.json @@ -596,6 +596,9 @@ "label": { "type": "keyword" }, + "tag": { + "type": "keyword" + }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/fess/doc.json b/src/main/resources/fess_indices/fess/doc.json index cd3b8ef50d..ba1fdfa45f 100644 --- a/src/main/resources/fess_indices/fess/doc.json +++ b/src/main/resources/fess_indices/fess/doc.json @@ -598,6 +598,9 @@ "label": { "type": "keyword" }, + "tag": { + "type": "keyword" + }, "virtual_host": { "type": "keyword" }, diff --git a/src/main/resources/fess_indices/fess_config.tag_type.json b/src/main/resources/fess_indices/fess_config.tag_type.json new file mode 100644 index 0000000000..8f94f93c21 --- /dev/null +++ b/src/main/resources/fess_indices/fess_config.tag_type.json @@ -0,0 +1,10 @@ +{ + "settings": { + "index": { + "refresh_interval": "1s", + "number_of_shards": 1, + "number_of_replicas": 0, + "auto_expand_replicas": "0-1" + } + } +} diff --git a/src/main/resources/fess_indices/fess_config.tag_type/alias/fess_config.json b/src/main/resources/fess_indices/fess_config.tag_type/alias/fess_config.json new file mode 100644 index 0000000000..0967ef424b --- /dev/null +++ b/src/main/resources/fess_indices/fess_config.tag_type/alias/fess_config.json @@ -0,0 +1 @@ +{} diff --git a/src/main/resources/fess_indices/fess_config.tag_type/tag_type.json b/src/main/resources/fess_indices/fess_config.tag_type/tag_type.json new file mode 100644 index 0000000000..d63771de37 --- /dev/null +++ b/src/main/resources/fess_indices/fess_config.tag_type/tag_type.json @@ -0,0 +1,34 @@ +{ + "properties": { + "name": { + "type": "keyword" + }, + "owner": { + "type": "keyword" + }, + "paths": { + "type": "keyword" + }, + "permissions": { + "type": "keyword" + }, + "virtualHost": { + "type": "keyword" + }, + "sortOrder": { + "type": "integer" + }, + "createdBy": { + "type": "keyword" + }, + "createdTime": { + "type": "long" + }, + "updatedBy": { + "type": "keyword" + }, + "updatedTime": { + "type": "long" + } + } +} diff --git a/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java new file mode 100644 index 0000000000..fe6f18a152 --- /dev/null +++ b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java @@ -0,0 +1,191 @@ +/* + * Copyright 2012-2025 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.app.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.codelibs.fesen.opensearch.action.DocWriteRequest.OpType; +import org.codelibs.fesen.opensearch.action.index.IndexAction; +import org.codelibs.fesen.opensearch.action.index.IndexRequest; +import org.codelibs.fesen.opensearch.action.index.IndexRequestBuilder; +import org.codelibs.fesen.opensearch.action.support.WriteRequest.RefreshPolicy; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.opensearch.config.allcommon.EsAbstractEntity.RequestOptionCall; +import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.junit.jupiter.api.Test; + +/** + * Covers the write paths of {@link TagTypeService}: a tag type is created only when its id is + * free, updated only when nobody changed it since it was read, and a lost race on either is + * reported as a {@link TagTypeConflictException}. + */ +public class TagTypeServiceTest extends UnitFessTestCase { + + private TagType createTagType() { + final TagType tagType = new TagType(); + tagType.setId("0123456789abcdef"); + tagType.setName("foo"); + tagType.setOwner("alice"); + tagType.setPaths(new String[] { "http://example.com/" }); + tagType.setPermissions(new String[] { "1alice" }); + return tagType; + } + + private static IndexRequest apply(final RequestOptionCall opLambda) { + final IndexRequestBuilder builder = new IndexRequestBuilder(null, IndexAction.INSTANCE); + if (opLambda != null) { + opLambda.callback(builder); + } + return builder.request(); + } + + private static final class RecordingTagTypeBhv extends TagTypeBhv { + IndexRequest insertRequest; + IndexRequest updateRequest; + RuntimeException failure; + + @Override + public void insert(final TagType entity, final RequestOptionCall opLambda) { + insertRequest = apply(opLambda); + if (failure != null) { + throw failure; + } + } + + @Override + public void update(final TagType entity, final RequestOptionCall opLambda) { + updateRequest = apply(opLambda); + if (failure != null) { + throw failure; + } + } + } + + private static TagTypeService createService(final TagTypeBhv bhv) { + final TagTypeService service = new TagTypeService(); + service.tagTypeBhv = bhv; + return service; + } + + @Test + public void test_insert_usesCreateOpType() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + + createService(bhv).insert(createTagType()); + + assertNotNull(bhv.insertRequest); + assertEquals(OpType.CREATE, bhv.insertRequest.opType()); + assertEquals(RefreshPolicy.IMMEDIATE, bhv.insertRequest.getRefreshPolicy()); + } + + @Test + public void test_update_passesSeqNoAndPrimaryTerm() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + final TagType tagType = createTagType(); + tagType.setSeqNo(5L); + tagType.setPrimaryTerm(2L); + + createService(bhv).update(tagType); + + assertNotNull(bhv.updateRequest); + assertEquals(5L, bhv.updateRequest.ifSeqNo()); + assertEquals(2L, bhv.updateRequest.ifPrimaryTerm()); + assertEquals(OpType.INDEX, bhv.updateRequest.opType()); + assertEquals(RefreshPolicy.IMMEDIATE, bhv.updateRequest.getRefreshPolicy()); + } + + @Test + public void test_update_withoutSeqNoIsRefused() { + // An update without the sequence number would overwrite a concurrent change unseen. + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + + assertThrows(IllegalArgumentException.class, () -> createService(bhv).update(createTagType())); + assertNull(bhv.updateRequest); + } + + @Test + public void test_insert_conflictThrowsTagTypeConflictException() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + bhv.failure = new IllegalStateException("insert failed", + new VersionConflictEngineException("[0123456789abcdef]: version conflict, document already exists")); + + final TagTypeConflictException e = assertThrows(TagTypeConflictException.class, () -> createService(bhv).insert(createTagType())); + assertSame(bhv.failure, e.getCause()); + } + + @Test + public void test_update_conflictThrowsTagTypeConflictException() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + bhv.failure = new VersionConflictEngineException("[0123456789abcdef]: version conflict, required seqNo [5]"); + final TagType tagType = createTagType(); + tagType.setSeqNo(5L); + tagType.setPrimaryTerm(2L); + + assertThrows(TagTypeConflictException.class, () -> createService(bhv).update(tagType)); + } + + @Test + public void test_update_conflictOverHttpThrowsTagTypeConflictException() { + // Over the HTTP client the conflict arrives as a generic exception carrying the error type. + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + bhv.failure = new IllegalStateException( + "OpenSearch exception [type=version_conflict_engine_exception, reason=[0123456789abcdef]: version conflict]"); + final TagType tagType = createTagType(); + tagType.setSeqNo(5L); + tagType.setPrimaryTerm(2L); + + assertThrows(TagTypeConflictException.class, () -> createService(bhv).update(tagType)); + } + + @Test + public void test_insert_otherFailureIsRethrownAsIs() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + bhv.failure = new IllegalStateException("mapper_parsing_exception"); + + final IllegalStateException e = assertThrows(IllegalStateException.class, () -> createService(bhv).insert(createTagType())); + assertSame(bhv.failure, e); + } + + @Test + public void test_getTagValue_encodesNameAndOwner() { + final TagType tagType = new TagType(); + tagType.setName("a:b"); + tagType.setOwner("user/1"); + + // Base64URL without padding, UTF-8, joined by ':' + assertEquals("YTpi:dXNlci8x", tagType.getTagValue()); + + tagType.setName("日本語"); + assertEquals("5pel5pys6Kqe:dXNlci8x", tagType.getTagValue()); + + tagType.setOwner(null); + assertNull(tagType.getTagValue()); + } + + /** + * Stand-in for {@code org.codelibs.fesen.opensearch.index.engine.VersionConflictEngineException}, + * recognized by its class name. + */ + private static final class VersionConflictEngineException extends RuntimeException { + private static final long serialVersionUID = 1L; + + VersionConflictEngineException(final String message) { + super(message); + } + } +} diff --git a/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientIndexSettingTest.java b/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientIndexSettingTest.java index ec6d92a9f8..473c4280f1 100644 --- a/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientIndexSettingTest.java +++ b/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientIndexSettingTest.java @@ -391,12 +391,100 @@ public void test_addMissingProperties_noPutWhenNothingIsMissing() throws Excepti } @Test - public void test_addMissingProperties_skipsDocumentIndex() { + public void test_addMissingProperties_documentIndexGetsOnlyTheTagField() { + // The document index is upgraded by a reindex, so none of its other bundled fields are + // added; the tag field is the one exception, because the first tag write would otherwise + // map it dynamically as text and break the terms facet and term filters on it. final PutRecordingClient client = new PutRecordingClient(); - client.addMissingProperties("fess", "doc", "fess.20260101000000000", toMappingMetadata(Map.of("url", Map.of("type", "keyword")))); + final LogCapturingAppender capture = LogCapturingAppender.attach(SearchEngineClient.class); + try { + client.addMissingProperties("fess", "doc", "fess.20260101000000000", + toMappingMetadata(Map.of("url", Map.of("type", "keyword")))); + + assertEquals(List.of("fess.20260101000000000"), client.putIndexNames); + assertEquals(List.of("{\"properties\":{\"tag\":{\"type\":\"keyword\"}}}"), client.putSources); + final List infos = capture.messagesAt(Level.INFO); + assertEquals(1, infos.size(), infos.toString()); + assertTrue(infos.get(0).contains("fess.20260101000000000"), infos.get(0)); + assertTrue(capture.warnings().isEmpty(), capture.warnings().toString()); + } finally { + capture.detach(); + } + } + + @Test + public void test_addMissingProperties_documentIndexWithKeywordTagIsLeftAlone() { + final PutRecordingClient client = new PutRecordingClient(); + + final LogCapturingAppender capture = LogCapturingAppender.attach(SearchEngineClient.class); + try { + client.addMissingProperties("fess", "doc", "fess.20260101000000000", + toMappingMetadata(Map.of("url", Map.of("type", "keyword"), "tag", Map.of("type", "keyword")))); + + assertTrue(client.putSources.isEmpty(), client.putSources.toString()); + assertTrue(capture.messagesAt(Level.INFO).isEmpty(), capture.messagesAt(Level.INFO).toString()); + assertTrue(capture.warnings().isEmpty(), capture.warnings().toString()); + } finally { + capture.detach(); + } + } + + @Test + public void test_addMissingProperties_documentIndexWithOtherTagTypeWarnsAndIsLeftAlone() { + // A tag field mapped dynamically before the upgrade cannot be changed in place; it is + // reported so that the operator reindexes, and never overwritten. + final PutRecordingClient client = new PutRecordingClient(); - assertTrue(client.putSources.isEmpty(), client.putSources.toString()); + final LogCapturingAppender capture = LogCapturingAppender.attach(SearchEngineClient.class); + try { + client.addMissingProperties("fess", "doc", "fess.20260101000000000", + toMappingMetadata(Map.of("tag", Map.of("type", "text", "fields", Map.of("keyword", Map.of("type", "keyword")))))); + + assertTrue(client.putSources.isEmpty(), client.putSources.toString()); + final List warnings = capture.warnings(); + assertEquals(1, warnings.size(), warnings.toString()); + assertTrue(warnings.get(0).contains("fess.20260101000000000"), warnings.get(0)); + assertTrue(warnings.get(0).contains("text"), warnings.get(0)); + } finally { + capture.detach(); + } + } + + @Test + public void test_addMissingProperties_documentIndexTagSkippedInJobProcesses() { + final String[] processProperties = + { "fess.crawler.process", "fess.thumbnail.process", "fess.suggest.process", "fess.chunk.process" }; + for (final String property : processProperties) { + final PutRecordingClient client = new PutRecordingClient(); + System.setProperty(property, "true"); + try { + client.addMissingProperties("fess", "doc", "fess.20260101000000000", + toMappingMetadata(Map.of("url", Map.of("type", "keyword")))); + + assertTrue(client.putSources.isEmpty(), property + ": " + client.putSources); + } finally { + System.clearProperty(property); + } + } + } + + @Test + public void test_addMissingProperties_documentIndexTagPutFailureLogsWarnAndDoesNotThrow() { + final PutRecordingClient client = new PutRecordingClient(); + client.putFailure = new IllegalStateException("put failed"); + + final LogCapturingAppender capture = LogCapturingAppender.attach(SearchEngineClient.class); + try { + client.addMissingProperties("fess", "doc", "fess.20260101000000000", + toMappingMetadata(Map.of("url", Map.of("type", "keyword")))); + + assertEquals(1, client.putSources.size()); + assertEquals(1, capture.warnings().size(), capture.warnings().toString()); + assertTrue(capture.warnings().get(0).contains("fess.20260101000000000"), capture.warnings().get(0)); + } finally { + capture.detach(); + } } @Test @@ -605,6 +693,15 @@ public void test_docJson_isValidJsonAfterSubstitution() throws Exception { } } + @Test + public void test_docJson_tagIsKeywordInEveryVariant() throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + for (final String path : DOC_JSON_PATHS) { + final JsonNode root = mapper.readTree(readResourceAsString(path)); + assertEquals(path, "keyword", root.path("properties").path("tag").path("type").asText()); + } + } + @Test public void test_docJson_engineAndSpaceTypeResolveFromConfiguredNonDefaultValue() throws Exception { // The decisive regression test for a hardcoded (rather than placeholder-driven) mapping: From e4a52265d0c80a21cd4bc3f31ea8b61962519814 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:22:31 +0900 Subject: [PATCH 03/14] feat(tagtype): add TagTypeHelper for tag values, visibility and URL lookup TagTypeHelper encodes a tag as Base64URL(name) + ":" + Base64URL(owner), parses such a value back strictly, derives the tag_type id as the SHA-256 of the value, and normalizes tag names (NFKC, collapsed whitespace, a code point limit, no control, format or unpaired surrogate characters). TagType keeps the one implementation of the encoding. Visibility: nothing is visible to a caller who is not logged in, and an admin search sees every tag. Otherwise a tag is visible when the request has no virtual host or the tag's, and the caller owns the tag or holds one of its permissions, the guest roles included. A shared tag stores the encoded role.search.guest.permissions next to the owner's user role. getVisibleTagTypes reads tag types by id without their paths and caches the result in the request; getVisibleTagValues lists the caller's own and permitted tags up to user.tag.visible.max.size. findTagValuesByUrls reads every tag whose paths hold one of the URLs through a cursor and matches the URLs exactly. applyTags sets the tag field of documents about to be indexed from those tags and removes it when a URL has none; it does nothing unless user.tag.enabled is true. Register the helper in app.xml and in the crawler process app.xml, where documents are indexed. --- .../codelibs/fess/helper/TagTypeHelper.java | 525 +++++++++++++++++ .../opensearch/config/exentity/TagType.java | 13 + .../org/codelibs/fess/util/ComponentUtil.java | 11 + src/main/resources/app.xml | 2 + .../WEB-INF/env/crawler/resources/app.xml | 2 + .../fess/helper/TagTypeHelperTest.java | 556 ++++++++++++++++++ 6 files changed, 1109 insertions(+) create mode 100644 src/main/java/org/codelibs/fess/helper/TagTypeHelper.java create mode 100644 src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java diff --git a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java new file mode 100644 index 0000000000..3195c34745 --- /dev/null +++ b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java @@ -0,0 +1,525 @@ +/* + * Copyright 2012-2025 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.helper; + +import java.nio.ByteBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.text.Normalizer; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.core.misc.Pair; +import org.codelibs.fess.Constants; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; +import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; +import org.dbflute.optional.OptionalThing; +import org.lastaflute.web.util.LaRequestUtil; + +import jakarta.annotation.Resource; + +/** + * Helper for user tags: the value a tag puts on a document, the tags a caller may see, and the + * tags of the documents being indexed. + * + *

    A tag is a {@code tag_type} document owned by one user. Its value, {@code b64url(name) + ":" + * + b64url(owner)}, is what the {@code tag} field of a document holds, and its id is the SHA-256 + * of that value. Indexing runs in the crawler and data store child processes, so this helper + * reads tag types through {@link TagTypeBhv} only, which {@code esflute_config.xml} registers in + * every container that boots {@code app.xml}.

    + */ +public class TagTypeHelper { + private static final Logger logger = LogManager.getLogger(TagTypeHelper.class); + + /** The prefix of the request attribute caching the visible tag types, followed by the search request type. */ + public static final String VISIBLE_TAG_TYPES_ATTRIBUTE = "fess.tagTypeHelper.visibleTagTypes."; + + private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + + /** The TagType behavior. */ + @Resource + protected TagTypeBhv tagTypeBhv; + + /** + * Default constructor. + */ + public TagTypeHelper() { + // do nothing + } + + /** + * Encodes a tag name and its owner into a tag value. + * + * @param name the normalized tag name + * @param owner the owner of the tag + * @return the tag value + */ + public String toTagValue(final String name, final String owner) { + return TagType.toTagValue(name, owner); + } + + /** + * Decodes a tag value into its name and owner. + * + * @param value the tag value + * @return the name and the owner, or empty when the value is not one {@link #toTagValue(String, String)} returns + */ + public Optional> parseTagValue(final String value) { + if (StringUtil.isEmpty(value)) { + return Optional.empty(); + } + final int pos = value.indexOf(':'); + if (pos <= 0 || pos == value.length() - 1 || value.indexOf(':', pos + 1) >= 0) { + return Optional.empty(); + } + final String name = decodePart(value.substring(0, pos)); + final String owner = decodePart(value.substring(pos + 1)); + if (name == null || owner == null || !value.equals(toTagValue(name, owner))) { + // not canonical: padding, stray bits or a lossy UTF-8 round trip + return Optional.empty(); + } + return Optional.of(new Pair<>(name, owner)); + } + + private String decodePart(final String part) { + try { + final byte[] bytes = Base64.getUrlDecoder().decode(part); + return StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(ByteBuffer.wrap(bytes)) + .toString(); + } catch (final IllegalArgumentException | CharacterCodingException e) { + return null; + } + } + + /** + * Returns the id of the tag type of a tag value. + * + * @param tagValue the tag value + * @return the SHA-256 of the value in lowercase hex + */ + public String toId(final String tagValue) { + try { + final MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(tagValue.getBytes(StandardCharsets.UTF_8))); + } catch (final NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 is not available.", e); + } + } + + /** + * Normalizes a tag name: NFKC, whitespace runs collapsed to one space and trimmed. A name + * that is then empty, longer than {@code user.tag.name.max.length} code points, or holds a + * control, format or unpaired surrogate character is refused. + * + * @param raw the name as entered + * @return the normalized name, or empty when the name is refused + */ + public Optional normalizeName(final String raw) { + if (raw == null) { + return Optional.empty(); + } + final String normalized = WHITESPACE_PATTERN.matcher(Normalizer.normalize(raw, Normalizer.Form.NFKC)).replaceAll(" ").trim(); + if (normalized.isEmpty() + || normalized.codePointCount(0, normalized.length()) > ComponentUtil.getFessConfig().getUserTagNameMaxLengthAsInteger()) { + return Optional.empty(); + } + final boolean refused = normalized.codePoints().anyMatch(cp -> { + final int type = Character.getType(cp); + return type == Character.CONTROL || type == Character.FORMAT || type == Character.SURROGATE; + }); + return refused ? Optional.empty() : Optional.of(normalized); + } + + /** + * Tests whether a tag is shared, that is, its permissions hold one of the guest roles. + * + * @param tagType the tag type + * @return true if every logged-in user may see the tag + */ + public boolean isShared(final TagType tagType) { + final String[] permissions = tagType.getPermissions(); + if (permissions == null || permissions.length == 0) { + return false; + } + final List guestRoleList = ComponentUtil.getFessConfig().getSearchGuestRoleList(); + for (final String permission : permissions) { + if (guestRoleList.contains(permission)) { + return true; + } + } + return false; + } + + /** + * Builds the permissions stored on a tag: the user role of the owner and, for a shared tag, + * the encoded {@code role.search.guest.permissions}. The guest user role that + * {@link FessConfig#getSearchGuestRoleList()} adds is not stored. + * + * @param owner the owner of the tag + * @param shared whether the tag is shared + * @return the permissions + */ + public String[] buildPermissions(final String owner, final boolean shared) { + final List permissions = new ArrayList<>(); + permissions.add(ComponentUtil.getSystemHelper().getSearchRoleByDirectoryUser(owner)); + if (shared) { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final String guestUserRole = fessConfig.getRoleSearchUserPrefix() + Constants.GUEST_USER; + for (final String role : fessConfig.getSearchGuestRoleList()) { + if (!guestUserRole.equals(role) && !permissions.contains(role)) { + permissions.add(role); + } + } + } + return permissions.toArray(String[]::new); + } + + /** + * Returns the tag types of the given values that the caller may see, without their paths. + * Nothing is visible to a caller who is not logged in; an admin search sees every tag. + * Otherwise a tag is visible when the virtual host of the request is not set or is the tag's, + * and the caller owns the tag or holds one of its permissions, the guest roles included. + * The result of each value is cached in the request. + * + * @param values the tag values + * @param type the search request type + * @return the visible tag types keyed by their value + */ + public Map getVisibleTagTypes(final Collection values, final SearchRequestType type) { + if (values == null || values.isEmpty()) { + return Collections.emptyMap(); + } + final String userId = getLoggedInUserId(); + if (userId == null) { + return Collections.emptyMap(); + } + + final Map> cache = getVisibleTagTypeCache(type); + final Set missing = new LinkedHashSet<>(); + for (final String value : values) { + if (value != null && !cache.containsKey(value)) { + missing.add(value); + } + } + if (!missing.isEmpty()) { + final Map fetched = fetchTagTypes(missing); + final boolean adminSearch = SearchRequestType.ADMIN_SEARCH == type; + final Set roles = adminSearch ? Collections.emptySet() : getViewerRoles(type); + final String virtualHostKey = adminSearch ? null : ComponentUtil.getVirtualHostHelper().getVirtualHostKey(); + for (final String value : missing) { + final TagType tagType = fetched.get(value); + final boolean visible = tagType != null && (adminSearch || isVisible(tagType, userId, roles, virtualHostKey)); + cache.put(value, visible ? Optional.of(tagType) : Optional.empty()); + } + } + + final Map result = new LinkedHashMap<>(); + for (final String value : values) { + if (value != null) { + cache.get(value).ifPresent(tagType -> result.put(value, tagType)); + } + } + return result; + } + + /** + * Returns the values of the tags the caller may see: the caller's own tags and those whose + * permissions the caller holds, limited to the virtual host of the request and to + * {@code user.tag.visible.max.size} tags. Empty for a caller who is not logged in. + * + * @param type the search request type + * @return the visible tag values + */ + public Set getVisibleTagValues(final SearchRequestType type) { + final String userId = getLoggedInUserId(); + if (userId == null) { + return Collections.emptySet(); + } + final boolean adminSearch = SearchRequestType.ADMIN_SEARCH == type; + final Set roles = adminSearch ? Collections.emptySet() : getViewerRoles(type); + final String virtualHostKey = adminSearch ? null : ComponentUtil.getVirtualHostHelper().getVirtualHostKey(); + final int maxSize = ComponentUtil.getFessConfig().getUserTagVisibleMaxSizeAsInteger(); + final List tagTypeList = tagTypeBhv.selectList(cb -> { + if (adminSearch) { + cb.query().matchAll(); + } else { + cb.query().bool((must, should, mustNot, filter) -> { + should.setOwner_Term(userId); + if (!roles.isEmpty()) { + should.setPermissions_InScope(roles); + } + if (StringUtil.isNotBlank(virtualHostKey)) { + filter.setVirtualHost_Term(virtualHostKey); + } + }, bool -> bool.minimumShouldMatch(1)); + } + cb.specify().columnName(); + cb.specify().columnOwner(); + cb.query().addOrderBy_SortOrder_Asc(); + cb.query().addOrderBy_Name_Asc(); + cb.fetchFirst(maxSize); + }); + final Set valueSet = new LinkedHashSet<>(); + for (final TagType tagType : tagTypeList) { + final String value = tagType.getTagValue(); + if (value != null) { + valueSet.add(value); + } + } + return valueSet; + } + + /** + * Finds the tags put on each of the given URLs. A URL matches a tag only when it equals one + * of the tag's paths. Every matching tag is returned, however many there are. + * + * @param urls the URLs of documents + * @return the tag values of each URL that has a tag + */ + public Map> findTagValuesByUrls(final Collection urls) { + final Set urlSet = new LinkedHashSet<>(); + if (urls != null) { + for (final String url : urls) { + if (StringUtil.isNotEmpty(url)) { + urlSet.add(url); + } + } + } + if (urlSet.isEmpty()) { + return Collections.emptyMap(); + } + final Map> result = new HashMap<>(); + tagTypeBhv.selectBulk(cb -> { + cb.query().setPaths_InScope(urlSet); + cb.specify().columnName(); + cb.specify().columnOwner(); + cb.specify().columnPaths(); + }, list -> { + for (final TagType tagType : list) { + final String value = tagType.getTagValue(); + final String[] paths = tagType.getPaths(); + if (value == null || paths == null) { + continue; + } + for (final String path : paths) { + if (urlSet.contains(path)) { + result.computeIfAbsent(path, k -> new LinkedHashSet<>()).add(value); + } + } + } + }); + return result; + } + + /** + * Sets the tag field of documents about to be indexed from the tags put on their URLs, and + * removes it from a document whose URL has no tag. Does nothing unless + * {@code user.tag.enabled} is true. When the tags cannot be read, the documents are left as + * they are. + * + * @param docList the documents + */ + public void applyTags(final List> docList) { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + if (!fessConfig.isUserTagEnabled() || docList == null || docList.isEmpty()) { + return; + } + final String urlField = fessConfig.getIndexFieldUrl(); + final String tagField = fessConfig.getIndexFieldTag(); + final Set urlSet = new LinkedHashSet<>(); + for (final Map doc : docList) { + if (doc.get(urlField) instanceof final String url) { + urlSet.add(url); + } + } + final Map> tagMap; + try { + tagMap = findTagValuesByUrls(urlSet); + } catch (final RuntimeException e) { + logger.warn("Failed to read the user tags of documents. Run tag_updater to restore them: documents={}", docList.size(), e); + return; + } + for (final Map doc : docList) { + final Set values = doc.get(urlField) instanceof final String url ? tagMap.get(url) : null; + if (values == null || values.isEmpty()) { + doc.remove(tagField); + } else { + doc.put(tagField, values.toArray(String[]::new)); + } + } + } + + /** + * Reads the tag types of the given values, without their paths. + * + * @param values the tag values + * @return the tag types found, keyed by their value + */ + protected Map fetchTagTypes(final Collection values) { + final Map idMap = new LinkedHashMap<>(); + for (final String value : values) { + if (parseTagValue(value).isPresent()) { + idMap.put(toId(value), value); + } + } + final Map result = new HashMap<>(); + if (idMap.isEmpty()) { + return result; + } + final int chunkSize = Math.max(1, ComponentUtil.getFessConfig().getPageTagtypeMaxFetchSizeAsInteger()); + final List idList = new ArrayList<>(idMap.keySet()); + for (int i = 0; i < idList.size(); i += chunkSize) { + final List ids = idList.subList(i, Math.min(i + chunkSize, idList.size())); + final List tagTypeList = tagTypeBhv.selectList(cb -> { + cb.query().setId_InScope(ids); + specifyColumnsWithoutPaths(cb); + cb.fetchFirst(ids.size()); + }); + for (final TagType tagType : tagTypeList) { + final String value = tagType.getTagValue(); + if (value != null && values.contains(value)) { + result.put(value, tagType); + } + } + } + return result; + } + + /** + * Fetches every field except {@code paths}. + * + * @param cb the condition bean + */ + protected void specifyColumnsWithoutPaths(final TagTypeCB cb) { + cb.specify().columnName(); + cb.specify().columnOwner(); + cb.specify().columnPermissions(); + cb.specify().columnVirtualHost(); + cb.specify().columnSortOrder(); + cb.specify().columnCreatedBy(); + cb.specify().columnCreatedTime(); + cb.specify().columnUpdatedBy(); + cb.specify().columnUpdatedTime(); + } + + /** + * Tests whether a tag is visible to a logged-in caller outside an admin search. + * + * @param tagType the tag type + * @param userId the user id of the caller + * @param roles the roles of the caller, the guest roles included + * @param virtualHostKey the virtual host key of the request + * @return true if the caller may see the tag + */ + protected boolean isVisible(final TagType tagType, final String userId, final Set roles, final String virtualHostKey) { + if (StringUtil.isNotBlank(virtualHostKey) && !virtualHostKey.equals(tagType.getVirtualHost())) { + return false; + } + if (userId.equals(tagType.getOwner())) { + return true; + } + final String[] permissions = tagType.getPermissions(); + if (permissions != null) { + for (final String permission : permissions) { + if (roles.contains(permission)) { + return true; + } + } + } + return false; + } + + /** + * Returns the roles of the caller together with the guest roles. + * + * @param type the search request type + * @return the roles + */ + protected Set getViewerRoles(final SearchRequestType type) { + final Set roles = new HashSet<>(ComponentUtil.getRoleQueryHelper().build(type)); + roles.addAll(ComponentUtil.getFessConfig().getSearchGuestRoleList()); + return roles; + } + + /** + * Returns the cache of visible tag types of the current request, or a map that is dropped + * after the call when there is no request, as in a job or a child process. + * + * @param type the search request type + * @return the cache, keyed by tag value; an empty optional marks a value that is not visible + */ + protected Map> getVisibleTagTypeCache(final SearchRequestType type) { + return LaRequestUtil.getOptionalRequest().map(request -> { + final String name = VISIBLE_TAG_TYPES_ATTRIBUTE + (type == null ? StringUtil.EMPTY : type.name()); + @SuppressWarnings("unchecked") + Map> cache = (Map>) request.getAttribute(name); + if (cache == null) { + cache = new HashMap<>(); + request.setAttribute(name, cache); + } + return cache; + }).orElseGet(HashMap::new); + } + + /** + * Returns the user id of the logged-in caller. + * + * @return the user id, or null when the caller is not logged in + */ + protected String getLoggedInUserId() { + return getSavedUserBean().map(FessUserBean::getUserId).filter(StringUtil::isNotBlank).orElse(null); + } + + /** + * Resolves the logged-in user. A failed lookup counts as a caller who is not logged in. + * + * @return the saved user bean, or empty + */ + protected OptionalThing getSavedUserBean() { + try { + return ComponentUtil.getFessLoginAssist().getSavedUserBean(); + } catch (final Exception e) { + logger.debug("Failed to resolve the logged-in user; treating the caller as not logged in.", e); + return OptionalThing.empty(); + } + } +} diff --git a/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java b/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java index eb4243715d..1746c69ded 100644 --- a/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java +++ b/src/main/java/org/codelibs/fess/opensearch/config/exentity/TagType.java @@ -72,6 +72,19 @@ public String getTagValue() { if (name == null || owner == null) { return null; } + return toTagValue(name, owner); + } + + /** + * Encodes a tag name and its owner into the value a tag puts on the tag field of a document: + * each encoded as Base64URL without padding from UTF-8, joined by {@code ':'}. Neither part + * can contain {@code ':'}, so the value splits back into the two unambiguously. + * + * @param name the tag name + * @param owner the owner of the tag + * @return the tag value + */ + public static String toTagValue(final String name, final String owner) { final Base64.Encoder encoder = Base64.getUrlEncoder().withoutPadding(); return encoder.encodeToString(name.getBytes(StandardCharsets.UTF_8)) + ":" + encoder.encodeToString(owner.getBytes(StandardCharsets.UTF_8)); diff --git a/src/main/java/org/codelibs/fess/util/ComponentUtil.java b/src/main/java/org/codelibs/fess/util/ComponentUtil.java index 85f4965ed0..48f6a95e95 100644 --- a/src/main/java/org/codelibs/fess/util/ComponentUtil.java +++ b/src/main/java/org/codelibs/fess/util/ComponentUtil.java @@ -86,6 +86,7 @@ import org.codelibs.fess.helper.SseResponseHelper; import org.codelibs.fess.helper.SuggestHelper; import org.codelibs.fess.helper.SystemHelper; +import org.codelibs.fess.helper.TagTypeHelper; import org.codelibs.fess.helper.ThemeArtifactHelper; import org.codelibs.fess.helper.UserAgentHelper; import org.codelibs.fess.helper.UserInfoHelper; @@ -244,6 +245,8 @@ public final class ComponentUtil { private static final String LABEL_TYPE_HELPER = "labelTypeHelper"; + private static final String TAG_TYPE_HELPER = "tagTypeHelper"; + private static final String QUERY_HELPER = "queryHelper"; private static final String QUERY_FIELD_CONFIG = "queryFieldConfig"; @@ -408,6 +411,14 @@ public static LabelTypeHelper getLabelTypeHelper() { return getComponent(LABEL_TYPE_HELPER); } + /** + * Gets the tag type helper component. + * @return The tag type helper. + */ + public static TagTypeHelper getTagTypeHelper() { + return getComponent(TAG_TYPE_HELPER); + } + /** * Gets the search log helper component. * @return The search log helper. diff --git a/src/main/resources/app.xml b/src/main/resources/app.xml index cda5be8084..57184385b2 100644 --- a/src/main/resources/app.xml +++ b/src/main/resources/app.xml @@ -63,6 +63,8 @@
    + + diff --git a/src/main/webapp/WEB-INF/env/crawler/resources/app.xml b/src/main/webapp/WEB-INF/env/crawler/resources/app.xml index f201aa3540..036aac3370 100644 --- a/src/main/webapp/WEB-INF/env/crawler/resources/app.xml +++ b/src/main/webapp/WEB-INF/env/crawler/resources/app.xml @@ -12,6 +12,8 @@ + + diff --git a/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java b/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java new file mode 100644 index 0000000000..58d9df3e7f --- /dev/null +++ b/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java @@ -0,0 +1,556 @@ +/* + * Copyright 2012-2025 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.helper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import org.codelibs.core.misc.Pair; +import org.codelibs.fesen.opensearch.action.search.SearchAction; +import org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder; +import org.codelibs.fesen.opensearch.search.builder.SearchSourceBuilder; +import org.codelibs.fess.entity.FessUser; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; +import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.codelibs.fess.util.ComponentUtil; +import org.dbflute.bhv.readable.CBCall; +import org.dbflute.bhv.readable.EntityRowHandler; +import org.dbflute.cbean.result.ListResultBean; +import org.dbflute.optional.OptionalThing; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +public class TagTypeHelperTest extends UnitFessTestCase { + + private TagTypeHelper tagTypeHelper; + + private MockTagTypeBhv tagTypeBhv; + + /** The logged-in user, or null for an anonymous caller. */ + private FessUserBean userBean; + + /** The roles the role query helper returns for the caller. */ + private Set roleSet; + + private String virtualHostKey; + + private boolean userTagEnabled; + + private List guestRoleList; + + @Override + protected void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + userBean = null; + roleSet = new LinkedHashSet<>(); + virtualHostKey = ""; + userTagEnabled = true; + guestRoleList = new ArrayList<>(List.of("Rguest", "1guest")); + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return userTagEnabled; + } + + @Override + public Integer getUserTagNameMaxLengthAsInteger() { + return 50; + } + + @Override + public Integer getUserTagVisibleMaxSizeAsInteger() { + return 1000; + } + + @Override + public Integer getPageTagtypeMaxFetchSizeAsInteger() { + return 1000; + } + + @Override + public String getIndexFieldTag() { + return "tag"; + } + + @Override + public String getIndexFieldUrl() { + return "url"; + } + + @Override + public String getRoleSearchUserPrefix() { + return "1"; + } + + @Override + public List getSearchGuestRoleList() { + return guestRoleList; + } + }); + ComponentUtil.register(new SystemHelper(), "systemHelper"); + ComponentUtil.register(new VirtualHostHelper() { + @Override + public String getVirtualHostKey() { + return virtualHostKey; + } + }, "virtualHostHelper"); + ComponentUtil.register(new RoleQueryHelper() { + @Override + public Set build(final SearchRequestType searchRequestType) { + return roleSet; + } + }, "roleQueryHelper"); + tagTypeBhv = new MockTagTypeBhv(); + tagTypeHelper = new TagTypeHelper() { + @Override + protected OptionalThing getSavedUserBean() { + return OptionalThing.ofNullable(userBean, () -> {}); + } + }; + tagTypeHelper.tagTypeBhv = tagTypeBhv; + } + + // ----------------------------------------------------------------------- + // value, id and name + // ----------------------------------------------------------------------- + + @Test + public void test_tagValue_roundTrip_specialChars() { + for (final String name : List.of("a:b", "x/y+z", "日本語", "😀 tag")) { + final String v = tagTypeHelper.toTagValue(name, "user:1"); + assertEquals(new Pair<>(name, "user:1"), tagTypeHelper.parseTagValue(v).get()); + assertFalse(v.contains("=")); + assertFalse(v.contains("/")); + assertFalse(v.contains("+")); + } + assertFalse(tagTypeHelper.toTagValue("a:b", "c").equals(tagTypeHelper.toTagValue("a", "b:c"))); + } + + @Test + public void test_tagValue_matchesEntity() { + final TagType tagType = createTagType("foo", "alice"); + assertEquals(tagType.getTagValue(), tagTypeHelper.toTagValue("foo", "alice")); + } + + @Test + public void test_parseTagValue_malformed() { + assertTrue(tagTypeHelper.parseTagValue(null).isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("Zm9v").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("Zm9v:").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue(":Zm9v").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("Zm9v:Zm9v:Zm9v").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("Zm9v:Zm9v=").isEmpty()); + assertTrue(tagTypeHelper.parseTagValue("Zm9v:*").isEmpty()); + // not valid UTF-8 (0xff) + assertTrue(tagTypeHelper.parseTagValue("_w:Zm9v").isEmpty()); + } + + @Test + public void test_toId_isSha256Hex() { + assertTrue(tagTypeHelper.toId("x").matches("[0-9a-f]{64}")); + // sha256("x") + assertEquals("2d711642b726b04401627ca9fbac32f5c8530fb1903cc4db02258717921a4881", tagTypeHelper.toId("x")); + } + + @Test + public void test_normalizeName() { + assertEquals("ABC x", tagTypeHelper.normalizeName("ABC x ").get()); + assertEquals("a b", tagTypeHelper.normalizeName("\ta \n b").get()); + assertTrue(tagTypeHelper.normalizeName(null).isEmpty()); + assertTrue(tagTypeHelper.normalizeName(" ").isEmpty()); + assertTrue(tagTypeHelper.normalizeName("a\u0000b").isEmpty()); + assertTrue(tagTypeHelper.normalizeName("a‎b").isEmpty()); + // a format character outside the BMP (TAG LATIN SMALL LETTER A) + assertTrue(tagTypeHelper.normalizeName("a󠁡b").isEmpty()); + // an unpaired surrogate + assertTrue(tagTypeHelper.normalizeName("a\uD800b").isEmpty()); + assertTrue(tagTypeHelper.normalizeName("a".repeat(51)).isEmpty()); + assertEquals("a".repeat(50), tagTypeHelper.normalizeName("a".repeat(50)).get()); + // counted in code points, not chars + assertEquals("😀".repeat(50), tagTypeHelper.normalizeName("😀".repeat(50)).get()); + } + + // ----------------------------------------------------------------------- + // permissions + // ----------------------------------------------------------------------- + + @Test + public void test_isShared() { + assertTrue(tagTypeHelper.isShared(createTagType("foo", "alice", "1alice", "Rguest"))); + assertFalse(tagTypeHelper.isShared(createTagType("foo", "alice", "1alice"))); + final TagType noPermissions = createTagType("foo", "alice"); + noPermissions.setPermissions(null); + assertFalse(tagTypeHelper.isShared(noPermissions)); + } + + @Test + public void test_buildPermissions() { + assertArrayEquals(new String[] { "1alice" }, tagTypeHelper.buildPermissions("alice", false)); + // the synthetic guest user entry of the guest role list is not stored + assertArrayEquals(new String[] { "1alice", "Rguest" }, tagTypeHelper.buildPermissions("alice", true)); + } + + // ----------------------------------------------------------------------- + // visibility + // ----------------------------------------------------------------------- + + @Test + public void test_visibility_ownerEvenWithoutUserPermission() { + // An LDAP-style user whose roles do not carry 1alice still sees the tags they own. + login("alice"); + roleSet.add("2group"); + final TagType tagType = store(createTagType("foo", "alice", "1alice")); + + final Map visible = tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON); + assertEquals(1, visible.size()); + assertSame(tagType, visible.get(tagType.getTagValue())); + } + + @Test + public void test_visibility_sharedVisibleToOtherLoggedInUser() { + login("bob"); + roleSet.add("1bob"); + final TagType tagType = store(createTagType("foo", "alice", "1alice", "Rguest")); + + final Map visible = tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON); + assertTrue(visible.containsKey(tagType.getTagValue())); + } + + @Test + public void test_visibility_privateHiddenFromOthers() { + login("bob"); + roleSet.add("1bob"); + final TagType tagType = store(createTagType("foo", "alice", "1alice")); + + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON).isEmpty()); + } + + @Test + public void test_visibility_adminSearchSeesAll() { + login("admin"); + final TagType tagType = store(createTagType("foo", "alice", "1alice")); + tagType.setVirtualHost("other"); + + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.ADMIN_SEARCH) + .containsKey(tagType.getTagValue())); + } + + @Test + public void test_visibility_anonymousSeesNothing() { + roleSet.add("Rguest"); + final TagType tagType = store(createTagType("foo", "alice", "1alice", "Rguest")); + + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON).isEmpty()); + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.ADMIN_SEARCH).isEmpty()); + assertTrue(tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON).isEmpty()); + assertEquals(0, tagTypeBhv.selectListCount.get()); + } + + @Test + public void test_visibility_virtualHostMismatchHidden() { + login("alice"); + final TagType tagType = store(createTagType("foo", "alice", "1alice")); + tagType.setVirtualHost("host1"); + + virtualHostKey = "host2"; + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON).isEmpty()); + clearRequestCache(); + virtualHostKey = "host1"; + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON) + .containsKey(tagType.getTagValue())); + clearRequestCache(); + virtualHostKey = ""; + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON) + .containsKey(tagType.getTagValue())); + } + + @Test + public void test_visibility_sharedFollowsGuestPermissionSetting() { + // role.search.guest.permissions={role}everyone + guestRoleList = new ArrayList<>(List.of("Reveryone", "1guest")); + assertArrayEquals(new String[] { "1alice", "Reveryone" }, tagTypeHelper.buildPermissions("alice", true)); + + login("bob"); + roleSet.add("1bob"); + final TagType oldShared = store(createTagType("old", "alice", "1alice", "Rguest")); + final TagType newShared = store(createTagType("new", "alice", tagTypeHelper.buildPermissions("alice", true))); + + assertFalse(tagTypeHelper.isShared(oldShared)); + assertTrue(tagTypeHelper.isShared(newShared)); + final Map visible = + tagTypeHelper.getVisibleTagTypes(List.of(oldShared.getTagValue(), newShared.getTagValue()), SearchRequestType.JSON); + assertEquals(Set.of(newShared.getTagValue()), visible.keySet()); + } + + @Test + public void test_getVisibleTagTypes_cachedPerRequest() { + login("alice"); + final TagType foo = store(createTagType("foo", "alice", "1alice")); + final TagType bar = store(createTagType("bar", "alice", "1alice")); + + tagTypeHelper.getVisibleTagTypes(List.of(foo.getTagValue()), SearchRequestType.JSON); + tagTypeHelper.getVisibleTagTypes(List.of(foo.getTagValue()), SearchRequestType.JSON); + assertEquals(1, tagTypeBhv.selectListCount.get()); + + final Map visible = + tagTypeHelper.getVisibleTagTypes(List.of(foo.getTagValue(), bar.getTagValue(), "unknown"), SearchRequestType.JSON); + assertEquals(2, tagTypeBhv.selectListCount.get()); + assertEquals(Set.of(foo.getTagValue(), bar.getTagValue()), visible.keySet()); + // the second query asked only for the values not cached yet + assertTrue(tagTypeBhv.lastQuery.contains(tagTypeHelper.toId(bar.getTagValue()))); + assertFalse(tagTypeBhv.lastQuery.contains(tagTypeHelper.toId(foo.getTagValue()))); + // paths are not fetched + assertFalse(tagTypeBhv.lastSpecifiedColumns.contains("paths")); + } + + @Test + public void test_getVisibleTagValues() { + login("alice"); + roleSet.add("1alice"); + roleSet.add("2dev"); + virtualHostKey = "host1"; + store(createTagType("foo", "alice", "1alice")); + store(createTagType("bar", "bob", "1bob", "Rguest")); + + final Set values = tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON); + assertEquals(Set.of(tagTypeHelper.toTagValue("foo", "alice"), tagTypeHelper.toTagValue("bar", "bob")), values); + final String query = tagTypeBhv.lastQuery; + assertTrue(query.contains("\"owner\""), query); + assertTrue(query.contains("\"alice\""), query); + assertTrue(query.contains("\"permissions\""), query); + assertTrue(query.contains("\"2dev\""), query); + assertTrue(query.contains("\"Rguest\""), query); + assertTrue(query.contains("\"virtualHost\""), query); + assertTrue(query.contains("\"host1\""), query); + assertTrue(query.contains("\"minimum_should_match\" : \"1\""), query); + assertEquals(1000, tagTypeBhv.lastFetchSize); + assertFalse(tagTypeBhv.lastSpecifiedColumns.contains("paths")); + } + + // ----------------------------------------------------------------------- + // URL lookup and indexing + // ----------------------------------------------------------------------- + + @Test + public void test_findTagValuesByUrls_exactMatch() { + final String url = "http://a/b c%20#x"; + store(createTagType("foo", "alice", "1alice")).setPaths(new String[] { url, "http://a/b" }); + store(createTagType("bar", "bob", "1bob")).setPaths(new String[] { "http://a/b c", "http://a/b c%20" }); + + final Map> result = tagTypeHelper.findTagValuesByUrls(List.of(url)); + assertEquals(Map.of(url, Set.of(tagTypeHelper.toTagValue("foo", "alice"))), result); + assertTrue(tagTypeBhv.lastQuery.contains("\"paths\""), tagTypeBhv.lastQuery); + } + + @Test + public void test_findTagValuesByUrls_moreThanOnePage() { + final String url = "http://example.com/"; + final Set expected = new LinkedHashSet<>(); + for (int i = 0; i < 25; i++) { + store(createTagType("tag" + i, "alice", "1alice")).setPaths(new String[] { url }); + expected.add(tagTypeHelper.toTagValue("tag" + i, "alice")); + } + + assertEquals(Map.of(url, expected), tagTypeHelper.findTagValuesByUrls(List.of(url))); + assertTrue(tagTypeBhv.selectBulkPages > 1); + } + + @Test + public void test_findTagValuesByUrls_empty() { + assertTrue(tagTypeHelper.findTagValuesByUrls(List.of()).isEmpty()); + assertEquals(0, tagTypeBhv.selectBulkPages); + } + + @Test + public void test_applyTags_setsAndRemovesField() { + store(createTagType("foo", "alice", "1alice")).setPaths(new String[] { "http://a/1" }); + store(createTagType("bar", "bob", "1bob", "Rguest")).setPaths(new String[] { "http://a/1", "http://a/2" }); + + final Map doc1 = new HashMap<>(Map.of("url", "http://a/1")); + final Map doc2 = new HashMap<>(Map.of("url", "http://a/2", "tag", new String[] { "stale" })); + final Map doc3 = new HashMap<>(Map.of("url", "http://a/3", "tag", new String[] { "stale" })); + final Map doc4 = new HashMap<>(Map.of("title", "no url", "tag", "stale")); + tagTypeHelper.applyTags(List.of(doc1, doc2, doc3, doc4)); + + assertEquals(Set.of(tagTypeHelper.toTagValue("foo", "alice"), tagTypeHelper.toTagValue("bar", "bob")), + Set.of((String[]) doc1.get("tag"))); + assertArrayEquals(new String[] { tagTypeHelper.toTagValue("bar", "bob") }, (String[]) doc2.get("tag")); + assertFalse(doc3.containsKey("tag")); + assertFalse(doc4.containsKey("tag")); + assertEquals(1, tagTypeBhv.selectBulkCalls); + } + + @Test + public void test_applyTags_disabledLeavesDocumentsUntouched() { + userTagEnabled = false; + store(createTagType("foo", "alice", "1alice")).setPaths(new String[] { "http://a/1" }); + final Map doc1 = new HashMap<>(Map.of("url", "http://a/1")); + final Map doc2 = new HashMap<>(Map.of("url", "http://a/2", "tag", "kept")); + + tagTypeHelper.applyTags(List.of(doc1, doc2)); + + assertFalse(doc1.containsKey("tag")); + assertEquals("kept", doc2.get("tag")); + assertEquals(0, tagTypeBhv.selectBulkCalls); + } + + @Test + public void test_applyTags_lookupFailureLeavesDocumentsUntouched() { + tagTypeBhv.failure = new IllegalStateException("search engine is down"); + final Map doc = new HashMap<>(Map.of("url", "http://a/1", "tag", "kept")); + + tagTypeHelper.applyTags(List.of(doc)); + + assertEquals("kept", doc.get("tag")); + } + + // ----------------------------------------------------------------------- + // helpers + // ----------------------------------------------------------------------- + + private static void assertArrayEquals(final Object[] expected, final Object[] actual) { + org.junit.jupiter.api.Assertions.assertArrayEquals(expected, actual); + } + + private void login(final String userId) { + userBean = new FessUserBean(new StubFessUser(userId)); + } + + private void clearRequestCache() { + for (final SearchRequestType type : SearchRequestType.values()) { + getMockRequest().removeAttribute(TagTypeHelper.VISIBLE_TAG_TYPES_ATTRIBUTE + type.name()); + } + } + + private TagType createTagType(final String name, final String owner, final String... permissions) { + final TagType tagType = new TagType(); + tagType.setName(name); + tagType.setOwner(owner); + tagType.setPermissions(permissions); + tagType.setVirtualHost(""); + tagType.setSortOrder(0); + tagType.setPaths(new String[0]); + return tagType; + } + + private TagType store(final TagType tagType) { + tagType.setId(tagTypeHelper.toId(tagType.getTagValue())); + tagTypeBhv.entities.add(tagType); + return tagType; + } + + /** + * Returns every stored entity from a query, so the helper must itself pick the ones asked + * for; records the query and the columns it fetched. + */ + private static class MockTagTypeBhv extends TagTypeBhv { + final List entities = new ArrayList<>(); + + final AtomicInteger selectListCount = new AtomicInteger(); + + int selectBulkCalls; + + int selectBulkPages; + + String lastQuery; + + Set lastSpecifiedColumns; + + int lastFetchSize; + + RuntimeException failure; + + private TagTypeCB capture(final CBCall cbLambda) { + if (failure != null) { + throw failure; + } + final TagTypeCB cb = new TagTypeCB(); + cbLambda.callback(cb); + lastQuery = String.valueOf(cb.query().getQuery()); + final SearchSourceBuilder source = cb.build(new SearchRequestBuilder(null, SearchAction.INSTANCE)).request().source(); + lastSpecifiedColumns = new LinkedHashSet<>(); + if (source != null && source.fetchSource() != null) { + lastSpecifiedColumns.addAll(List.of(source.fetchSource().includes())); + } + lastFetchSize = cb.isFetchScopeEffective() ? cb.getFetchSize() : -1; + return cb; + } + + @Override + public ListResultBean selectList(final CBCall cbLambda) { + capture(cbLambda); + selectListCount.incrementAndGet(); + final ListResultBean list = new ListResultBean<>(); + list.setSelectedList(new ArrayList<>(entities)); + return list; + } + + @Override + public void selectBulk(final CBCall cbLambda, final EntityRowHandler> entityLambda) { + selectBulkCalls++; + capture(cbLambda); + // hand the entities over in pages of 10, like a cursor over the search engine + for (int i = 0; i < entities.size(); i += 10) { + selectBulkPages++; + entityLambda.handle(new ArrayList<>(entities.subList(i, Math.min(i + 10, entities.size())))); + } + } + } + + private static class StubFessUser implements FessUser { + private static final long serialVersionUID = 1L; + + private final String name; + + StubFessUser(final String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String[] getRoleNames() { + return new String[0]; + } + + @Override + public String[] getGroupNames() { + return new String[0]; + } + + @Override + public String[] getPermissions() { + // an LDAP-style user whose permissions do not carry the user role + return new String[] { "2group" }; + } + } +} From e0e93b3186cd3f91629132ce957fc188e6295188 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:28:11 +0900 Subject: [PATCH 04/14] fix(tagtype): keep own tags within the visible tag cap getVisibleTagValues read the caller's own tags and the tags of others in one query sorted by sort order and name, so tags of others could push the caller's own tags past user.tag.visible.max.size. Read the own tags first and fill the rest of the budget with the tags of others whose permissions the caller holds. The guest role list ends with the guest user role, which is also the own permission of a user named guest. Their private tags were therefore taken as shared and shown to every logged-in user. Only the encoded role.search.guest.permissions now mark a tag as shared, and the guest user role no longer grants visibility; the owner still sees the tag. --- .../codelibs/fess/helper/TagTypeHelper.java | 101 +++++++++++++----- .../fess/helper/TagTypeHelperTest.java | 100 +++++++++++++++-- 2 files changed, 162 insertions(+), 39 deletions(-) diff --git a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java index 3195c34745..03ec83fccc 100644 --- a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java +++ b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java @@ -170,7 +170,9 @@ public Optional normalizeName(final String raw) { } /** - * Tests whether a tag is shared, that is, its permissions hold one of the guest roles. + * Tests whether a tag is shared, that is, its permissions hold one of the encoded + * {@code role.search.guest.permissions}. The guest user role is not one of them, so the + * private tag of a user named {@code guest} is not shared. * * @param tagType the tag type * @return true if every logged-in user may see the tag @@ -180,9 +182,9 @@ public boolean isShared(final TagType tagType) { if (permissions == null || permissions.length == 0) { return false; } - final List guestRoleList = ComponentUtil.getFessConfig().getSearchGuestRoleList(); + final List sharedRoleList = getSharedRoleList(); for (final String permission : permissions) { - if (guestRoleList.contains(permission)) { + if (sharedRoleList.contains(permission)) { return true; } } @@ -202,10 +204,8 @@ public String[] buildPermissions(final String owner, final boolean shared) { final List permissions = new ArrayList<>(); permissions.add(ComponentUtil.getSystemHelper().getSearchRoleByDirectoryUser(owner)); if (shared) { - final FessConfig fessConfig = ComponentUtil.getFessConfig(); - final String guestUserRole = fessConfig.getRoleSearchUserPrefix() + Constants.GUEST_USER; - for (final String role : fessConfig.getSearchGuestRoleList()) { - if (!guestUserRole.equals(role) && !permissions.contains(role)) { + for (final String role : getSharedRoleList()) { + if (!permissions.contains(role)) { permissions.add(role); } } @@ -217,7 +217,7 @@ public String[] buildPermissions(final String owner, final boolean shared) { * Returns the tag types of the given values that the caller may see, without their paths. * Nothing is visible to a caller who is not logged in; an admin search sees every tag. * Otherwise a tag is visible when the virtual host of the request is not set or is the tag's, - * and the caller owns the tag or holds one of its permissions, the guest roles included. + * and the caller owns the tag or holds one of its permissions, the sharing roles included. * The result of each value is cached in the request. * * @param values the tag values @@ -262,8 +262,8 @@ public Map getVisibleTagTypes(final Collection values, } /** - * Returns the values of the tags the caller may see: the caller's own tags and those whose - * permissions the caller holds, limited to the virtual host of the request and to + * Returns the values of the tags the caller may see: the caller's own tags and then those + * of others whose permissions the caller holds, limited to the virtual host of the request and to * {@code user.tag.visible.max.size} tags. Empty for a caller who is not logged in. * * @param type the search request type @@ -278,36 +278,57 @@ public Set getVisibleTagValues(final SearchRequestType type) { final Set roles = adminSearch ? Collections.emptySet() : getViewerRoles(type); final String virtualHostKey = adminSearch ? null : ComponentUtil.getVirtualHostHelper().getVirtualHostKey(); final int maxSize = ComponentUtil.getFessConfig().getUserTagVisibleMaxSizeAsInteger(); - final List tagTypeList = tagTypeBhv.selectList(cb -> { - if (adminSearch) { + final List tagTypeList = new ArrayList<>(); + if (adminSearch) { + tagTypeList.addAll(tagTypeBhv.selectList(cb -> { cb.query().matchAll(); - } else { + specifyNameAndOwner(cb); + cb.fetchFirst(maxSize); + })); + } else { + // the caller's own tags first, so that tags of others never push them out + tagTypeList.addAll(tagTypeBhv.selectList(cb -> { cb.query().bool((must, should, mustNot, filter) -> { - should.setOwner_Term(userId); - if (!roles.isEmpty()) { - should.setPermissions_InScope(roles); - } + filter.setOwner_Term(userId); if (StringUtil.isNotBlank(virtualHostKey)) { filter.setVirtualHost_Term(virtualHostKey); } - }, bool -> bool.minimumShouldMatch(1)); + }); + specifyNameAndOwner(cb); + cb.fetchFirst(maxSize); + })); + final int remaining = maxSize - tagTypeList.size(); + if (remaining > 0 && !roles.isEmpty()) { + tagTypeList.addAll(tagTypeBhv.selectList(cb -> { + cb.query().bool((must, should, mustNot, filter) -> { + filter.setPermissions_InScope(roles); + mustNot.setOwner_Term(userId); + if (StringUtil.isNotBlank(virtualHostKey)) { + filter.setVirtualHost_Term(virtualHostKey); + } + }); + specifyNameAndOwner(cb); + cb.fetchFirst(remaining); + })); } - cb.specify().columnName(); - cb.specify().columnOwner(); - cb.query().addOrderBy_SortOrder_Asc(); - cb.query().addOrderBy_Name_Asc(); - cb.fetchFirst(maxSize); - }); + } final Set valueSet = new LinkedHashSet<>(); for (final TagType tagType : tagTypeList) { final String value = tagType.getTagValue(); - if (value != null) { + if (value != null && valueSet.size() < maxSize) { valueSet.add(value); } } return valueSet; } + private void specifyNameAndOwner(final TagTypeCB cb) { + cb.specify().columnName(); + cb.specify().columnOwner(); + cb.query().addOrderBy_SortOrder_Asc(); + cb.query().addOrderBy_Name_Asc(); + } + /** * Finds the tags put on each of the given URLs. A URL matches a tag only when it equals one * of the tag's paths. Every matching tag is returned, however many there are. @@ -446,7 +467,7 @@ protected void specifyColumnsWithoutPaths(final TagTypeCB cb) { * * @param tagType the tag type * @param userId the user id of the caller - * @param roles the roles of the caller, the guest roles included + * @param roles the roles of the caller, the sharing roles included * @param virtualHostKey the virtual host key of the request * @return true if the caller may see the tag */ @@ -469,17 +490,41 @@ protected boolean isVisible(final TagType tagType, final String userId, final Se } /** - * Returns the roles of the caller together with the guest roles. + * Returns the roles of the caller together with the sharing roles, without the guest user role. * * @param type the search request type * @return the roles */ protected Set getViewerRoles(final SearchRequestType type) { final Set roles = new HashSet<>(ComponentUtil.getRoleQueryHelper().build(type)); - roles.addAll(ComponentUtil.getFessConfig().getSearchGuestRoleList()); + roles.addAll(getSharedRoleList()); + // the guest user role is the own permission of a user named guest, not a sharing role + roles.remove(getGuestUserRole()); return roles; } + /** + * Returns the roles that mark a tag as shared: the encoded + * {@code role.search.guest.permissions}, without the guest user role that + * {@link FessConfig#getSearchGuestRoleList()} adds. + * + * @return the sharing roles + */ + protected List getSharedRoleList() { + final String guestUserRole = getGuestUserRole(); + final List list = new ArrayList<>(); + for (final String role : ComponentUtil.getFessConfig().getSearchGuestRoleList()) { + if (!guestUserRole.equals(role)) { + list.add(role); + } + } + return list; + } + + private String getGuestUserRole() { + return ComponentUtil.getFessConfig().getRoleSearchUserPrefix() + Constants.GUEST_USER; + } + /** * Returns the cache of visible tag types of the current request, or a map that is dropped * after the call when there is no request, as in a job or a child process. diff --git a/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java b/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java index 58d9df3e7f..1c5fd4142a 100644 --- a/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java +++ b/src/test/java/org/codelibs/fess/helper/TagTypeHelperTest.java @@ -61,6 +61,8 @@ public class TagTypeHelperTest extends UnitFessTestCase { private List guestRoleList; + private int visibleMaxSize; + @Override protected void setUp(final TestInfo testInfo) throws Exception { super.setUp(testInfo); @@ -69,6 +71,7 @@ protected void setUp(final TestInfo testInfo) throws Exception { virtualHostKey = ""; userTagEnabled = true; guestRoleList = new ArrayList<>(List.of("Rguest", "1guest")); + visibleMaxSize = 1000; ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { private static final long serialVersionUID = 1L; @@ -84,7 +87,7 @@ public Integer getUserTagNameMaxLengthAsInteger() { @Override public Integer getUserTagVisibleMaxSizeAsInteger() { - return 1000; + return visibleMaxSize; } @Override @@ -340,19 +343,83 @@ public void test_getVisibleTagValues() { final Set values = tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON); assertEquals(Set.of(tagTypeHelper.toTagValue("foo", "alice"), tagTypeHelper.toTagValue("bar", "bob")), values); - final String query = tagTypeBhv.lastQuery; - assertTrue(query.contains("\"owner\""), query); - assertTrue(query.contains("\"alice\""), query); - assertTrue(query.contains("\"permissions\""), query); - assertTrue(query.contains("\"2dev\""), query); - assertTrue(query.contains("\"Rguest\""), query); - assertTrue(query.contains("\"virtualHost\""), query); - assertTrue(query.contains("\"host1\""), query); - assertTrue(query.contains("\"minimum_should_match\" : \"1\""), query); - assertEquals(1000, tagTypeBhv.lastFetchSize); + assertEquals(2, tagTypeBhv.queries.size()); + // own tags first + final String ownQuery = tagTypeBhv.queries.get(0); + assertTrue(ownQuery.contains("\"owner\""), ownQuery); + assertTrue(ownQuery.contains("\"alice\""), ownQuery); + assertFalse(ownQuery.contains("\"permissions\""), ownQuery); + assertTrue(ownQuery.contains("\"host1\""), ownQuery); + // then tags of others the caller holds a permission of, with the rest of the budget + final String otherQuery = tagTypeBhv.queries.get(1); + assertTrue(otherQuery.contains("\"permissions\""), otherQuery); + assertTrue(otherQuery.contains("\"2dev\""), otherQuery); + assertTrue(otherQuery.contains("\"Rguest\""), otherQuery); + assertFalse(otherQuery.contains("\"1guest\""), otherQuery); + assertTrue(otherQuery.contains("\"must_not\""), otherQuery); + assertTrue(otherQuery.contains("\"alice\""), otherQuery); + assertTrue(otherQuery.contains("\"virtualHost\""), otherQuery); + assertTrue(otherQuery.contains("\"host1\""), otherQuery); + // the mock answered both stored tags to the first query + assertEquals(998, tagTypeBhv.lastFetchSize); assertFalse(tagTypeBhv.lastSpecifiedColumns.contains("paths")); } + @Test + public void test_getVisibleTagValues_ownTagsKeptWithinCap() { + login("alice"); + roleSet.add("1alice"); + final List own = new ArrayList<>(); + for (int i = 0; i < 3; i++) { + own.add(store(createTagType("own" + i, "alice", "1alice"))); + } + final List others = new ArrayList<>(); + // sorted before the own tags by sortOrder + for (int i = 0; i < 3; i++) { + final TagType tagType = store(createTagType("shared" + i, "bob", "1bob", "Rguest")); + tagType.setSortOrder(-1); + others.add(tagType); + } + // the owner query answers own tags; the permission query (must_not owner) answers others + tagTypeBhv.responder = query -> query.contains("must_not") ? others : own; + + visibleMaxSize = 3; + assertEquals(Set.of(own.get(0).getTagValue(), own.get(1).getTagValue(), own.get(2).getTagValue()), + tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON)); + // the budget was spent on own tags, so the permission query is not sent + assertEquals(1, tagTypeBhv.selectListCount.get()); + + visibleMaxSize = 4; + final Set values = tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON); + assertEquals(4, values.size()); + for (final TagType tagType : own) { + assertTrue(values.contains(tagType.getTagValue())); + } + assertTrue(values.contains(others.get(0).getTagValue())); + assertEquals(1, tagTypeBhv.lastFetchSize); + } + + @Test + public void test_guestNamedOwner_privateTagNotShared() { + // a user named guest has the guest user role 1guest as their own permission + final TagType tagType = store(createTagType("foo", "guest", tagTypeHelper.buildPermissions("guest", false))); + assertArrayEquals(new String[] { "1guest" }, tagType.getPermissions()); + assertFalse(tagTypeHelper.isShared(tagType)); + + // bob's roles may carry the guest roles, 1guest included + login("bob"); + roleSet.addAll(List.of("1bob", "Rguest", "1guest")); + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON).isEmpty()); + tagTypeHelper.getVisibleTagValues(SearchRequestType.JSON); + assertFalse(tagTypeBhv.lastQuery.contains("\"1guest\""), tagTypeBhv.lastQuery); + + // the owner still sees it + clearRequestCache(); + login("guest"); + assertTrue(tagTypeHelper.getVisibleTagTypes(List.of(tagType.getTagValue()), SearchRequestType.JSON) + .containsKey(tagType.getTagValue())); + } + // ----------------------------------------------------------------------- // URL lookup and indexing // ----------------------------------------------------------------------- @@ -480,12 +547,17 @@ private static class MockTagTypeBhv extends TagTypeBhv { String lastQuery; + final List queries = new ArrayList<>(); + Set lastSpecifiedColumns; int lastFetchSize; RuntimeException failure; + /** When set, answers selectList from the query, truncated to the fetch size. */ + java.util.function.Function> responder; + private TagTypeCB capture(final CBCall cbLambda) { if (failure != null) { throw failure; @@ -493,6 +565,7 @@ private TagTypeCB capture(final CBCall cbLambda) { final TagTypeCB cb = new TagTypeCB(); cbLambda.callback(cb); lastQuery = String.valueOf(cb.query().getQuery()); + queries.add(lastQuery); final SearchSourceBuilder source = cb.build(new SearchRequestBuilder(null, SearchAction.INSTANCE)).request().source(); lastSpecifiedColumns = new LinkedHashSet<>(); if (source != null && source.fetchSource() != null) { @@ -507,6 +580,11 @@ public ListResultBean selectList(final CBCall cbLambda) { capture(cbLambda); selectListCount.incrementAndGet(); final ListResultBean list = new ListResultBean<>(); + if (responder != null) { + final List answer = responder.apply(lastQuery); + list.setSelectedList(new ArrayList<>(answer.subList(0, Math.min(answer.size(), lastFetchSize)))); + return list; + } list.setSelectedList(new ArrayList<>(entities)); return list; } From ceeac1f5eb27f8d339edb83f9fc59cc969b8021b Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:43:11 +0900 Subject: [PATCH 05/14] feat(tagtype): queue tag changes and apply them to documents in bulk TagTypeHelper#enqueue keeps TagChange values (ADD, REMOVE, DELETE, RENAME) in an in-memory queue bounded by user.tag.queue.max.size; a change beyond the limit is dropped with a WARN. processQueue, run by the log_aggregator job in its own try block when user.tag.enabled is true, applies them in order: additions and removals are gathered per URL as the final state of each value and written in bulk, user.tag.process.batch.size URLs at a time, to every document of each URL; a DELETE or RENAME first writes what was gathered and then updates every document holding the value through updateByQuery. A failed write is logged and the remaining changes are still applied. Each update targets index.document.update.index, retries on version conflicts and appends the language script so content_*/title_* stay indexed. The painless code tolerates a missing, single or list tag field, never adds a value twice, and ends with an expression because the appended language statements cannot follow a closing brace. The new tag_updater scheduled job (no schedule, target all) walks every document, reads the tags of each page of URLs with one query and rewrites only the documents whose tags differ, removing the field when the URL has no tag. It restores changes lost from the queue. --- .../org/codelibs/fess/entity/TagChange.java | 82 ++++ .../codelibs/fess/helper/TagTypeHelper.java | 199 +++++++++ .../codelibs/fess/job/AggregateLogJob.java | 12 +- .../org/codelibs/fess/job/TagUpdaterJob.java | 193 +++++++++ .../scheduled_job.bulk | 2 + src/main/resources/fess_job.xml | 2 + .../fess/helper/TagTypeHelperQueueTest.java | 381 ++++++++++++++++++ .../fess/job/AggregateLogJobTest.java | 66 ++- .../codelibs/fess/job/TagUpdaterJobTest.java | 262 ++++++++++++ .../ShippedScriptCompilationTest.java | 2 +- 10 files changed, 1198 insertions(+), 3 deletions(-) create mode 100644 src/main/java/org/codelibs/fess/entity/TagChange.java create mode 100644 src/main/java/org/codelibs/fess/job/TagUpdaterJob.java create mode 100644 src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java create mode 100644 src/test/java/org/codelibs/fess/job/TagUpdaterJobTest.java diff --git a/src/main/java/org/codelibs/fess/entity/TagChange.java b/src/main/java/org/codelibs/fess/entity/TagChange.java new file mode 100644 index 0000000000..7b02838f5c --- /dev/null +++ b/src/main/java/org/codelibs/fess/entity/TagChange.java @@ -0,0 +1,82 @@ +/* + * Copyright 2012-2025 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.entity; + +/** + * A change of user tags waiting to be applied to the documents of the search index. + * + * @param type the kind of change + * @param value the tag value; the old value for {@link Type#RENAME} + * @param newValue the new tag value of {@link Type#RENAME}, otherwise null + * @param url the URL of the documents of {@link Type#ADD} and {@link Type#REMOVE}, otherwise null + */ +public record TagChange(Type type, String value, String newValue, String url) { + + /** The kind of a tag change. */ + public enum Type { + /** Puts a tag on the documents of a URL. */ + ADD, + /** Takes a tag off the documents of a URL. */ + REMOVE, + /** Takes a tag off every document. */ + DELETE, + /** Replaces a tag value with another on every document. */ + RENAME + } + + /** + * Creates a change that puts a tag on the documents of a URL. + * + * @param value the tag value + * @param url the URL + * @return the change + */ + public static TagChange add(final String value, final String url) { + return new TagChange(Type.ADD, value, null, url); + } + + /** + * Creates a change that takes a tag off the documents of a URL. + * + * @param value the tag value + * @param url the URL + * @return the change + */ + public static TagChange remove(final String value, final String url) { + return new TagChange(Type.REMOVE, value, null, url); + } + + /** + * Creates a change that takes a tag off every document. + * + * @param value the tag value + * @return the change + */ + public static TagChange delete(final String value) { + return new TagChange(Type.DELETE, value, null, null); + } + + /** + * Creates a change that replaces a tag value with another on every document. + * + * @param oldValue the current tag value + * @param newValue the new tag value + * @return the change + */ + public static TagChange rename(final String oldValue, final String newValue) { + return new TagChange(Type.RENAME, oldValue, newValue, null); + } +} diff --git a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java index 03ec83fccc..ca0f958690 100644 --- a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java +++ b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java @@ -34,14 +34,22 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Queue; import java.util.Set; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.codelibs.core.lang.StringUtil; import org.codelibs.core.misc.Pair; +import org.codelibs.fesen.opensearch.action.update.UpdateRequest; +import org.codelibs.fesen.opensearch.index.query.QueryBuilders; +import org.codelibs.fesen.opensearch.script.Script; +import org.codelibs.fesen.opensearch.script.ScriptType; import org.codelibs.fess.Constants; +import org.codelibs.fess.entity.TagChange; import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; import org.codelibs.fess.mylasta.action.FessUserBean; import org.codelibs.fess.mylasta.direction.FessConfig; @@ -49,6 +57,7 @@ import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; import org.codelibs.fess.opensearch.config.exentity.TagType; import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fess.util.DocumentUtil; import org.dbflute.optional.OptionalThing; import org.lastaflute.web.util.LaRequestUtil; @@ -72,10 +81,19 @@ public class TagTypeHelper { private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+"); + /** How many times an update retries on a version conflict. */ + protected static final int RETRY_ON_CONFLICT = 3; + /** The TagType behavior. */ @Resource protected TagTypeBhv tagTypeBhv; + /** The tag changes waiting to be applied to the documents. */ + protected final Queue tagChangeQueue = new ConcurrentLinkedQueue<>(); + + /** The number of changes in {@link #tagChangeQueue}. */ + protected final AtomicInteger tagChangeQueueSize = new AtomicInteger(); + /** * Default constructor. */ @@ -409,6 +427,187 @@ public void applyTags(final List> docList) { } } + /** + * Queues a tag change to be applied to the documents by {@link #processQueue()}. A change is + * dropped when {@code user.tag.queue.max.size} changes are already waiting. + * + * @param change the tag change + * @return true if the change was queued + */ + public boolean enqueue(final TagChange change) { + if (change == null || change.type() == null) { + return false; + } + final int maxSize = ComponentUtil.getFessConfig().getUserTagQueueMaxSizeAsInteger(); + if (tagChangeQueueSize.incrementAndGet() > maxSize) { + tagChangeQueueSize.decrementAndGet(); + logger.warn("The tag change queue is full. Run tag_updater to restore the dropped change: type={}, limit={}", change.type(), + maxSize); + return false; + } + tagChangeQueue.add(change); + return true; + } + + /** + * Applies the queued tag changes to the documents, in the order they were queued. Additions + * and removals are gathered per URL as the final state of each value and written in bulk, + * {@code user.tag.process.batch.size} URLs at a time; a deletion or a rename first writes what + * was gathered and then updates every document holding the value. A failed write is logged + * and the remaining changes are still applied. + * + * @return the number of changes taken from the queue + */ + public synchronized int processQueue() { + final List changeList = new ArrayList<>(); + TagChange change; + while ((change = tagChangeQueue.poll()) != null) { + tagChangeQueueSize.decrementAndGet(); + changeList.add(change); + } + if (changeList.isEmpty()) { + return 0; + } + final Map> pendingMap = new LinkedHashMap<>(); + for (final TagChange tagChange : changeList) { + switch (tagChange.type()) { + case ADD, REMOVE -> { + if (StringUtil.isNotEmpty(tagChange.url()) && StringUtil.isNotEmpty(tagChange.value())) { + pendingMap.computeIfAbsent(tagChange.url(), k -> new LinkedHashMap<>()) + .put(tagChange.value(), tagChange.type() == TagChange.Type.ADD); + } + } + case DELETE -> { + flushTagChanges(pendingMap); + updateDocumentsWithTag(tagChange.value(), Collections.emptyList(), List.of(tagChange.value())); + } + case RENAME -> { + flushTagChanges(pendingMap); + if (!StringUtil.equals(tagChange.value(), tagChange.newValue()) && StringUtil.isNotEmpty(tagChange.newValue())) { + updateDocumentsWithTag(tagChange.value(), List.of(tagChange.newValue()), List.of(tagChange.value())); + } + } + default -> logger.warn("Unknown tag change: {}", tagChange.type()); + } + } + flushTagChanges(pendingMap); + return changeList.size(); + } + + /** + * Writes the gathered additions and removals to every document of their URLs, and clears them. + * + * @param pendingMap whether each value is to be added (true) or removed (false), keyed by URL + */ + protected void flushTagChanges(final Map> pendingMap) { + if (pendingMap.isEmpty()) { + return; + } + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final int batchSize = Math.max(1, fessConfig.getUserTagProcessBatchSizeAsInteger()); + final List urlList = new ArrayList<>(pendingMap.keySet()); + for (int i = 0; i < urlList.size(); i += batchSize) { + final List urls = urlList.subList(i, Math.min(i + batchSize, urlList.size())); + int documents = 0; + try { + final List requestList = new ArrayList<>(); + final String index = fessConfig.getIndexDocumentUpdateIndex(); + ComponentUtil.getSearchEngineClient().scrollSearch(index, builder -> { + builder.setQuery(QueryBuilders.termsQuery(fessConfig.getIndexFieldUrl(), urls)) + .setFetchSource(new String[] { fessConfig.getIndexFieldUrl(), fessConfig.getIndexFieldLang() }, null) + .setSize(batchSize); + return true; + }, doc -> { + final String id = DocumentUtil.getValue(doc, fessConfig.getIndexFieldId(), String.class); + final String url = DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class); + final Map valueMap = url == null ? null : pendingMap.get(url); + if (id != null && valueMap != null) { + final List addList = new ArrayList<>(); + final List removeList = new ArrayList<>(); + valueMap.forEach((value, add) -> (add ? addList : removeList).add(value)); + requestList.add(new UpdateRequest(index, id).script(createUpdateTagScript(doc, addList, removeList)) + .retryOnConflict(RETRY_ON_CONFLICT)); + } + return true; + }); + documents = requestList.size(); + if (!requestList.isEmpty()) { + ComponentUtil.getSearchHelper().bulkUpdate(builder -> requestList.forEach(builder::add)); + } + } catch (final Exception e) { + logger.warn("Failed to update the user tags of documents. Run tag_updater to restore them: urls={}, documents={}", + urls.size(), documents, e); + } + } + pendingMap.clear(); + } + + /** + * Updates every document holding a tag value. A failure is logged. + * + * @param value the tag value the documents hold + * @param addList the values to put on the documents + * @param removeList the values to take off the documents + */ + protected void updateDocumentsWithTag(final String value, final List addList, final List removeList) { + if (StringUtil.isEmpty(value)) { + return; + } + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + try { + final long count = ComponentUtil.getSearchEngineClient() + .updateByQuery(fessConfig.getIndexDocumentUpdateIndex(), + builder -> builder.setQuery(QueryBuilders.termQuery(fessConfig.getIndexFieldTag(), value)) + .setFetchSource(new String[] { fessConfig.getIndexFieldLang() }, null), + (builder, hit) -> builder.setScript(createUpdateTagScript(hit.getSourceAsMap(), addList, removeList)) + .setRetryOnConflict(RETRY_ON_CONFLICT)); + if (logger.isDebugEnabled()) { + logger.debug("Updated the user tag of documents: added={}, removed={}, documents={}", addList.size(), removeList.size(), + count); + } + } catch (final Exception e) { + logger.warn("Failed to update the user tag of documents. Run tag_updater to restore them: added={}, removed={}", addList.size(), + removeList.size(), e); + } + } + + /** + * Builds the painless code that puts the values of {@code params.add} on a document and takes + * those of {@code params.remove} off it. The field may be missing, a single value or a list; a + * value is never added twice. The two parameters never share a value, so the order of adding + * and removing does not matter, and the code ends with an expression so that + * {@link LanguageHelper#createScript(Map, String)} can append its statements: painless has no + * empty statement to follow a closing brace. + * + * @param field the tag field + * @return the painless code + */ + protected static String buildUpdateTagScript(final String field) { + final String f = "ctx._source." + field; + return "if(" + f + "==null){" + f + "=new ArrayList();}" // + + "else if(!(" + f + " instanceof List)){" + f + "=new ArrayList([" + f + "]);}" // + + "for(v in params.add){if(!" + f + ".contains(v)){" + f + ".add(v);}}" // + + f + ".removeIf(x -> params.remove.contains(x))"; + } + + /** + * Builds the script that puts values on a document and takes others off it, followed by the + * statements that keep its language fields. + * + * @param doc the document, holding its language + * @param addList the values to put on the document + * @param removeList the values to take off the document + * @return the script + */ + protected Script createUpdateTagScript(final Map doc, final List addList, final List removeList) { + final Script script = + ComponentUtil.getLanguageHelper().createScript(doc, buildUpdateTagScript(ComponentUtil.getFessConfig().getIndexFieldTag())); + final Map params = new HashMap<>(); + params.put("add", addList); + params.put("remove", removeList); + return new Script(ScriptType.INLINE, "painless", script.getIdOrCode(), params); + } + /** * Reads the tag types of the given values, without their paths. * diff --git a/src/main/java/org/codelibs/fess/job/AggregateLogJob.java b/src/main/java/org/codelibs/fess/job/AggregateLogJob.java index cfba746fbc..431a6b6d5f 100644 --- a/src/main/java/org/codelibs/fess/job/AggregateLogJob.java +++ b/src/main/java/org/codelibs/fess/job/AggregateLogJob.java @@ -22,7 +22,8 @@ /** * Job for aggregating and storing search logs. - * This job processes search logs and stores them in the search log repository. + * This job processes search logs and stores them in the search log repository, and applies the + * queued user tag changes to the documents. */ public class AggregateLogJob { @@ -53,6 +54,15 @@ public String execute() { resultBuf.append(e.getMessage()).append("\n"); } + try { + if (ComponentUtil.getFessConfig().isUserTagEnabled()) { + ComponentUtil.getTagTypeHelper().processQueue(); + } + } catch (final Exception e) { + logger.warn("Failed to apply user tag changes.", e); + resultBuf.append(e.getMessage()).append("\n"); + } + return resultBuf.toString(); } diff --git a/src/main/java/org/codelibs/fess/job/TagUpdaterJob.java b/src/main/java/org/codelibs/fess/job/TagUpdaterJob.java new file mode 100644 index 0000000000..8d5896380b --- /dev/null +++ b/src/main/java/org/codelibs/fess/job/TagUpdaterJob.java @@ -0,0 +1,193 @@ +/* + * Copyright 2012-2025 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.job; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.fesen.opensearch.action.update.UpdateRequest; +import org.codelibs.fesen.opensearch.index.query.QueryBuilders; +import org.codelibs.fesen.opensearch.script.Script; +import org.codelibs.fesen.opensearch.script.ScriptType; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fess.util.DocumentUtil; + +/** + * Job that rebuilds the user tags of every document from the tag types, the way + * {@link UpdateLabelJob} rebuilds labels. It recovers tag changes lost from the in-memory queue, + * for example on a restart, and tags lost while {@code user.tag.enabled} was false. + * + *

    Every document is walked, because a document that holds a stale tag and one whose URL gained + * a tag must both be found; the tags of each page of {@code user.tag.process.batch.size} + * documents are read with one query, and only the documents whose tags differ are written.

    + */ +public class TagUpdaterJob { + + private static final Logger logger = LogManager.getLogger(TagUpdaterJob.class); + + /** How many times an update retries on a version conflict. */ + protected static final int RETRY_ON_CONFLICT = 3; + + /** The number of documents walked. */ + protected long checked; + + /** The number of documents whose tags were written. */ + protected long updated; + + /** The number of documents whose tags could not be written. */ + protected long failed; + + /** + * Default constructor. + */ + public TagUpdaterJob() { + // do nothing + } + + /** + * Rebuilds the user tags of every document. + * + * @return the result message + */ + public String execute() { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + if (!fessConfig.isUserTagEnabled()) { + return "User tags are disabled.\n"; + } + final int batchSize = Math.max(1, fessConfig.getUserTagProcessBatchSizeAsInteger()); + final String index = fessConfig.getIndexDocumentUpdateIndex(); + final StringBuilder resultBuf = new StringBuilder(); + try { + final List> docList = new ArrayList<>(); + ComponentUtil.getSearchEngineClient().scrollSearch(index, builder -> { + builder.setQuery(QueryBuilders.matchAllQuery()) + .setFetchSource(new String[] { fessConfig.getIndexFieldUrl(), fessConfig.getIndexFieldLang(), + fessConfig.getIndexFieldTag() }, null) + .setSize(batchSize); + return true; + }, doc -> { + docList.add(doc); + if (docList.size() >= batchSize) { + updateTags(index, docList); + docList.clear(); + } + return true; + }); + updateTags(index, docList); + } catch (final Exception e) { + logger.warn("Could not update user tags.", e); + resultBuf.append(e.getMessage()).append("\n"); + } + resultBuf.append(updated) + .append(" documents updated (") + .append(checked) + .append(" documents checked, ") + .append(failed) + .append(" failed)\n"); + return resultBuf.toString(); + } + + /** + * Writes the tags of the documents of one page whose tags differ from those of their URLs. + * + * @param index the index to write + * @param docList the documents, holding their id, URL, language and tags + */ + protected void updateTags(final String index, final List> docList) { + if (docList.isEmpty()) { + return; + } + checked += docList.size(); + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final String urlField = fessConfig.getIndexFieldUrl(); + final String tagField = fessConfig.getIndexFieldTag(); + final Set urlSet = new LinkedHashSet<>(); + for (final Map doc : docList) { + final String url = DocumentUtil.getValue(doc, urlField, String.class); + if (url != null) { + urlSet.add(url); + } + } + final List requestList = new ArrayList<>(); + try { + final Map> tagMap = ComponentUtil.getTagTypeHelper().findTagValuesByUrls(urlSet); + for (final Map doc : docList) { + final String id = DocumentUtil.getValue(doc, fessConfig.getIndexFieldId(), String.class); + if (id == null) { + continue; + } + final String url = DocumentUtil.getValue(doc, urlField, String.class); + final Set values = url == null ? null : tagMap.get(url); + final Set expected = values == null ? Collections.emptySet() : values; + if (expected.equals(toValueSet(doc.get(tagField)))) { + continue; + } + final Script script; + if (expected.isEmpty()) { + script = ComponentUtil.getLanguageHelper().createScript(doc, "ctx._source.remove('" + tagField + "')"); + } else { + final Script base = ComponentUtil.getLanguageHelper().createScript(doc, "ctx._source." + tagField + "=params.tag"); + final Map params = new HashMap<>(); + params.put("tag", new ArrayList<>(expected)); + script = new Script(ScriptType.INLINE, "painless", base.getIdOrCode(), params); + } + requestList.add(new UpdateRequest(index, id).script(script).retryOnConflict(RETRY_ON_CONFLICT)); + } + if (!requestList.isEmpty()) { + ComponentUtil.getSearchHelper().bulkUpdate(builder -> requestList.forEach(builder::add)); + updated += requestList.size(); + } + } catch (final Exception e) { + failed += requestList.isEmpty() ? docList.size() : requestList.size(); + logger.warn("Failed to update the user tags of documents: documents={}, updates={}", docList.size(), requestList.size(), e); + } + } + + /** + * Reads the tag field of a document, which may be missing, a single value or a list. + * + * @param value the field value + * @return the tag values + */ + protected Set toValueSet(final Object value) { + final Set valueSet = new LinkedHashSet<>(); + if (value instanceof final Collection collection) { + for (final Object v : collection) { + if (v != null) { + valueSet.add(v.toString()); + } + } + } else if (value instanceof final Object[] array) { + for (final Object v : array) { + if (v != null) { + valueSet.add(v.toString()); + } + } + } else if (value != null) { + valueSet.add(value.toString()); + } + return valueSet; + } +} diff --git a/src/main/resources/fess_indices/fess_config.scheduled_job/scheduled_job.bulk b/src/main/resources/fess_indices/fess_config.scheduled_job/scheduled_job.bulk index d93ce0b290..86cb956683 100644 --- a/src/main/resources/fess_indices/fess_config.scheduled_job/scheduled_job.bulk +++ b/src/main/resources/fess_indices/fess_config.scheduled_job/scheduled_job.bulk @@ -26,3 +26,5 @@ {"name":"Log Notification","target":"all","cronExpression":"*/5 * * * *","scriptType":"javascript","scriptData":"return container.getComponent(\"logNotificationJob\").execute();","jobLogging":false,"crawler":false,"available":true,"sortOrder":13,"createdBy":"system","createdTime":0,"updatedBy":"system","updatedTime":0} {"index":{"_index":"fess_config.scheduled_job","_id":"content-chunk-vector-indexer"}} {"name":"Content Chunk Vector Indexer","target":"all","cronExpression":"0 13 * * *","scriptType":"javascript","scriptData":"return container.getComponent(\"chunkVectorJob\").logLevel(\"info\").sessionId(\"CHUNK\").execute(executor);","jobLogging":true,"crawler":false,"available":false,"sortOrder":14,"createdBy":"system","createdTime":0,"updatedBy":"system","updatedTime":0} +{"index":{"_index":"fess_config.scheduled_job","_id":"tag_updater"}} +{"name":"Tag Updater","target":"all","cronExpression":"","scriptType":"javascript","scriptData":"return container.getComponent(\"tagUpdaterJob\").execute();","jobLogging":false,"crawler":false,"available":true,"sortOrder":15,"createdBy":"system","createdTime":0,"updatedBy":"system","updatedTime":0} diff --git a/src/main/resources/fess_job.xml b/src/main/resources/fess_job.xml index 495e738287..aa2100e26f 100644 --- a/src/main/resources/fess_job.xml +++ b/src/main/resources/fess_job.xml @@ -22,6 +22,8 @@
    + + diff --git a/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java b/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java new file mode 100644 index 0000000000..1f454d0f20 --- /dev/null +++ b/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java @@ -0,0 +1,381 @@ +/* + * Copyright 2012-2025 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.helper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.codelibs.fesen.opensearch.action.DocWriteRequest; +import org.codelibs.fesen.opensearch.action.bulk.BulkAction; +import org.codelibs.fesen.opensearch.action.bulk.BulkRequestBuilder; +import org.codelibs.fesen.opensearch.action.search.SearchAction; +import org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder; +import org.codelibs.fesen.opensearch.action.update.UpdateAction; +import org.codelibs.fesen.opensearch.action.update.UpdateRequest; +import org.codelibs.fesen.opensearch.action.update.UpdateRequestBuilder; +import org.codelibs.fesen.opensearch.core.common.bytes.BytesArray; +import org.codelibs.fesen.opensearch.index.query.QueryBuilder; +import org.codelibs.fesen.opensearch.index.query.TermQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.TermsQueryBuilder; +import org.codelibs.fesen.opensearch.script.Script; +import org.codelibs.fesen.opensearch.search.SearchHit; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.client.SearchEngineClient; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fess.util.BooleanFunction; +import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchCondition; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +public class TagTypeHelperQueueTest extends UnitFessTestCase { + + private TagTypeHelper tagTypeHelper; + + /** Documents of the fess index, keyed by URL. */ + private Map>> docMap; + + /** What the search engine was asked to do, in order. */ + private List events; + + /** Update requests of each bulk call. */ + private List> bulkCalls; + + /** Update requests built by each updateByQuery call. */ + private List updateByQueryRequests; + + /** The bulk calls, counted from 1, that throw. */ + private List failingBulkCalls; + + private int queueMaxSize; + + private int batchSize; + + @Override + protected void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + docMap = new LinkedHashMap<>(); + events = new ArrayList<>(); + bulkCalls = new ArrayList<>(); + updateByQueryRequests = new ArrayList<>(); + failingBulkCalls = new ArrayList<>(); + queueMaxSize = 10000; + batchSize = 100; + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return true; + } + + @Override + public Integer getUserTagQueueMaxSizeAsInteger() { + return queueMaxSize; + } + + @Override + public Integer getUserTagProcessBatchSizeAsInteger() { + return batchSize; + } + + @Override + public String getIndexFieldTag() { + return "tag"; + } + + @Override + public String getIndexFieldUrl() { + return "url"; + } + + @Override + public String getIndexFieldLang() { + return "lang"; + } + + @Override + public String getIndexFieldId() { + return "_id"; + } + + @Override + public String getIndexDocumentUpdateIndex() { + return "fess.update"; + } + }); + final LanguageHelper languageHelper = new LanguageHelper(); + languageHelper.langFields = new String[] { "content", "title" }; + ComponentUtil.register(languageHelper, "languageHelper"); + ComponentUtil.register(new SearchEngineClient() { + @Override + public long scrollSearch(final String index, final SearchCondition condition, + final BooleanFunction> cursor) { + final SearchRequestBuilder builder = new SearchRequestBuilder(null, SearchAction.INSTANCE); + condition.build(builder); + final QueryBuilder query = builder.request().source().query(); + final TermsQueryBuilder termsQuery = (TermsQueryBuilder) query; + assertEquals("url", termsQuery.fieldName()); + events.add("search:" + index + ":" + termsQuery.values()); + long count = 0; + for (final Object url : termsQuery.values()) { + for (final Map doc : docMap.getOrDefault(url.toString(), List.of())) { + count++; + cursor.apply(new HashMap<>(doc)); + } + } + return count; + } + + @Override + public long updateByQuery(final String index, final Function option, + final BiFunction builder) { + final SearchRequestBuilder searchBuilder = option.apply(new SearchRequestBuilder(null, SearchAction.INSTANCE)); + final TermQueryBuilder termQuery = (TermQueryBuilder) searchBuilder.request().source().query(); + events.add("updateByQuery:" + index + ":" + termQuery.fieldName() + "=" + termQuery.value()); + final SearchHit hit = new SearchHit(0, "d1", null, null); + hit.sourceRef(new BytesArray("{\"lang\":\"ja\"}")); + final UpdateRequestBuilder requestBuilder = + builder.apply(new UpdateRequestBuilder(null, UpdateAction.INSTANCE, index, hit.getId()), hit); + updateByQueryRequests.add(requestBuilder.request()); + return 1; + } + }, "searchEngineClient"); + ComponentUtil.register(new SearchHelper() { + @Override + public boolean bulkUpdate(final Consumer consumer) { + final BulkRequestBuilder builder = new BulkRequestBuilder(null, BulkAction.INSTANCE); + consumer.accept(builder); + final List requests = new ArrayList<>(); + for (final DocWriteRequest request : builder.request().requests()) { + requests.add((UpdateRequest) request); + } + bulkCalls.add(requests); + events.add("bulk:" + requests.size()); + if (failingBulkCalls.contains(bulkCalls.size())) { + throw new IllegalStateException("bulk failed"); + } + return true; + } + }, "searchHelper"); + tagTypeHelper = new TagTypeHelper(); + } + + private void addDoc(final String id, final String url, final String lang) { + final Map doc = new HashMap<>(); + doc.put("_id", id); + doc.put("url", url); + if (lang != null) { + doc.put("lang", lang); + } + docMap.computeIfAbsent(url, k -> new ArrayList<>()).add(doc); + } + + private static Object scriptField(final Script script, final String name) { + try { + final java.lang.reflect.Field field = Script.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(script); + } catch (final ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map paramsOf(final Script script) { + return (Map) scriptField(script, "params"); + } + + @SuppressWarnings("unchecked") + private static List param(final UpdateRequest request, final String name) { + return (List) paramsOf(request.script()).get(name); + } + + @Test + public void test_processQueue_addThenRemove() { + addDoc("d1", "http://example.com/a", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.remove("v", "http://example.com/a"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals(1, bulkCalls.size()); + assertEquals(1, bulkCalls.get(0).size()); + final UpdateRequest request = bulkCalls.get(0).get(0); + assertEquals("fess.update", request.index()); + assertEquals("d1", request.id()); + assertEquals(List.of(), param(request, "add")); + assertEquals(List.of("v"), param(request, "remove")); + assertTrue(request.retryOnConflict() > 0); + assertEquals("painless", scriptField(request.script(), "lang")); + // the queue is drained + assertEquals(0, tagTypeHelper.processQueue()); + assertEquals(1, bulkCalls.size()); + } + + @Test + public void test_processQueue_deleteThenAdd() { + addDoc("d1", "http://example.com/a", null); + assertTrue(tagTypeHelper.enqueue(TagChange.remove("v", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("v"))); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/a"))); + + assertEquals(3, tagTypeHelper.processQueue()); + + assertEquals(List.of("search:fess.update:[http://example.com/a]", "bulk:1", "updateByQuery:fess.update:tag=v", + "search:fess.update:[http://example.com/a]", "bulk:1"), events); + assertEquals(List.of("v"), param(bulkCalls.get(0).get(0), "remove")); + final UpdateRequest deleteRequest = updateByQueryRequests.get(0); + assertEquals(List.of(), param(deleteRequest, "add")); + assertEquals(List.of("v"), param(deleteRequest, "remove")); + assertTrue(deleteRequest.retryOnConflict() > 0); + assertTrue(deleteRequest.script().getIdOrCode().contains(";ctx._source.content_ja=ctx._source.content")); + final UpdateRequest addRequest = bulkCalls.get(1).get(0); + assertEquals(List.of("v"), param(addRequest, "add")); + assertEquals(List.of(), param(addRequest, "remove")); + } + + @Test + public void test_processQueue_rename() { + assertTrue(tagTypeHelper.enqueue(TagChange.rename("old", "new"))); + + assertEquals(1, tagTypeHelper.processQueue()); + + assertEquals(List.of("updateByQuery:fess.update:tag=old"), events); + final UpdateRequest request = updateByQueryRequests.get(0); + assertEquals(List.of("new"), param(request, "add")); + assertEquals(List.of("old"), param(request, "remove")); + } + + @Test + public void test_flush_updatesAllDocsOfUrl() { + final String url = "http://example.com/a b%20c#frag"; + addDoc("d1", url, null); + addDoc("d2", url, "en"); + addDoc("d3", "http://example.com/other", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", url))); + + assertEquals(1, tagTypeHelper.processQueue()); + + assertEquals(List.of("search:fess.update:[" + url + "]", "bulk:2"), events); + final List requests = bulkCalls.get(0); + assertEquals("d1", requests.get(0).id()); + assertEquals("d2", requests.get(1).id()); + for (final UpdateRequest request : requests) { + assertEquals(List.of("v"), param(request, "add")); + assertEquals(List.of(), param(request, "remove")); + } + } + + @Test + public void test_flush_appendsLanguageScript() { + addDoc("d1", "http://example.com/a", "ja"); + addDoc("d2", "http://example.com/b", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/b"))); + + tagTypeHelper.processQueue(); + + final List requests = bulkCalls.get(0); + final String jaSource = requests.get(0).script().getIdOrCode(); + assertTrue(jaSource.startsWith(TagTypeHelper.buildUpdateTagScript("tag")), jaSource); + assertTrue(jaSource.endsWith(";ctx._source.content_ja=ctx._source.content;ctx._source.title_ja=ctx._source.title"), jaSource); + assertEquals(TagTypeHelper.buildUpdateTagScript("tag"), requests.get(1).script().getIdOrCode()); + } + + @Test + public void test_enqueue_overflowDropped() { + queueMaxSize = 2; + addDoc("d1", "http://example.com/a", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v1", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v2", "http://example.com/a"))); + assertFalse(tagTypeHelper.enqueue(TagChange.add("v3", "http://example.com/a"))); + assertFalse(tagTypeHelper.enqueue(null)); + + assertEquals(2, tagTypeHelper.processQueue()); + assertEquals(List.of("v1", "v2"), param(bulkCalls.get(0).get(0), "add")); + // room again after the queue is drained + assertTrue(tagTypeHelper.enqueue(TagChange.add("v3", "http://example.com/a"))); + } + + @Test + public void test_processQueue_failureContinues() { + batchSize = 1; + addDoc("d1", "http://example.com/a", null); + addDoc("d2", "http://example.com/b", null); + failingBulkCalls.add(1); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/b"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals(List.of("search:fess.update:[http://example.com/a]", "bulk:1", "search:fess.update:[http://example.com/b]", "bulk:1"), + events); + assertEquals("d2", bulkCalls.get(1).get(0).id()); + } + + @Test + public void test_processQueue_updateByQueryFailureContinues() { + addDoc("d1", "http://example.com/a", null); + ComponentUtil.register(new SearchEngineClient() { + @Override + public long scrollSearch(final String index, final SearchCondition condition, + final BooleanFunction> cursor) { + events.add("search"); + return cursor.apply(new HashMap<>(docMap.get("http://example.com/a").get(0))) ? 1 : 0; + } + + @Override + public long updateByQuery(final String index, final Function option, + final BiFunction builder) { + events.add("updateByQuery"); + throw new IllegalStateException("item failed"); + } + }, "searchEngineClient"); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("v"))); + assertTrue(tagTypeHelper.enqueue(TagChange.add("w", "http://example.com/a"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals(List.of("updateByQuery", "search", "bulk:1"), events); + } + + @Test + public void test_processQueue_concurrentEnqueue() throws Exception { + addDoc("d1", "http://example.com/a", null); + final Thread thread = new Thread(() -> { + for (int i = 0; i < 50; i++) { + tagTypeHelper.enqueue(TagChange.add("v" + i, "http://example.com/a")); + } + }); + thread.start(); + int processed = 0; + while (thread.isAlive()) { + processed += tagTypeHelper.processQueue(); + Thread.sleep(1L); + } + thread.join(); + processed += tagTypeHelper.processQueue(); + assertEquals(50, processed); + } +} diff --git a/src/test/java/org/codelibs/fess/job/AggregateLogJobTest.java b/src/test/java/org/codelibs/fess/job/AggregateLogJobTest.java index ccca850082..0ac0dd173b 100644 --- a/src/test/java/org/codelibs/fess/job/AggregateLogJobTest.java +++ b/src/test/java/org/codelibs/fess/job/AggregateLogJobTest.java @@ -15,7 +15,12 @@ */ package org.codelibs.fess.job; +import java.util.ArrayList; +import java.util.List; + import org.codelibs.fess.helper.SearchLogHelper; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.direction.FessConfig; import org.codelibs.fess.unit.UnitFessTestCase; import org.codelibs.fess.util.ComponentUtil; import org.junit.jupiter.api.Test; @@ -352,4 +357,63 @@ public void storeSearchLog() { // Fifth execution - RuntimeException assertTrue(aggregateLogJob.execute().contains("Generic error")); } -} \ No newline at end of file + + private List registerTagMocks(final boolean enabled, final boolean failTags) { + final List calls = new ArrayList<>(); + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return enabled; + } + }); + ComponentUtil.register(new SearchLogHelper() { + @Override + public void storeSearchLog() { + calls.add("searchLog"); + throw new RuntimeException("search log failed"); + } + }, "searchLogHelper"); + ComponentUtil.register(new TagTypeHelper() { + @Override + public synchronized int processQueue() { + calls.add("tags"); + if (failTags) { + throw new RuntimeException("tags failed"); + } + return 0; + } + }, "tagTypeHelper"); + return calls; + } + + @Test + public void test_execute_processesTagQueueAfterSearchLogFailure() { + final List calls = registerTagMocks(true, false); + + final String result = aggregateLogJob.execute(); + + assertEquals(List.of("searchLog", "tags"), calls); + assertEquals("search log failed\n", result); + } + + @Test + public void test_execute_tagQueueFailureReported() { + final List calls = registerTagMocks(true, true); + + final String result = aggregateLogJob.execute(); + + assertEquals(List.of("searchLog", "tags"), calls); + assertEquals("search log failed\ntags failed\n", result); + } + + @Test + public void test_execute_tagQueueSkippedWhenDisabled() { + final List calls = registerTagMocks(false, false); + + aggregateLogJob.execute(); + + assertEquals(List.of("searchLog"), calls); + } +} diff --git a/src/test/java/org/codelibs/fess/job/TagUpdaterJobTest.java b/src/test/java/org/codelibs/fess/job/TagUpdaterJobTest.java new file mode 100644 index 0000000000..f95f58af80 --- /dev/null +++ b/src/test/java/org/codelibs/fess/job/TagUpdaterJobTest.java @@ -0,0 +1,262 @@ +/* + * Copyright 2012-2025 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.job; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.codelibs.fesen.opensearch.action.DocWriteRequest; +import org.codelibs.fesen.opensearch.action.bulk.BulkAction; +import org.codelibs.fesen.opensearch.action.bulk.BulkRequestBuilder; +import org.codelibs.fesen.opensearch.action.search.SearchAction; +import org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder; +import org.codelibs.fesen.opensearch.action.update.UpdateRequest; +import org.codelibs.fesen.opensearch.script.Script; +import org.codelibs.fess.helper.LanguageHelper; +import org.codelibs.fess.helper.SearchHelper; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.client.SearchEngineClient; +import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchCondition; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.codelibs.fess.util.BooleanFunction; +import org.codelibs.fess.util.ComponentUtil; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +public class TagUpdaterJobTest extends UnitFessTestCase { + + /** The documents the walk hands out, in order. */ + private List> docList; + + /** The tags of each URL. */ + private Map> tagMap; + + /** The URLs of each findTagValuesByUrls call. */ + private List> lookups; + + private List updates; + + private List searchedIndices; + + private boolean userTagEnabled; + + private boolean failBulk; + + @Override + protected void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + docList = new ArrayList<>(); + tagMap = new HashMap<>(); + lookups = new ArrayList<>(); + updates = new ArrayList<>(); + searchedIndices = new ArrayList<>(); + userTagEnabled = true; + failBulk = false; + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return userTagEnabled; + } + + @Override + public Integer getUserTagProcessBatchSizeAsInteger() { + return 2; + } + + @Override + public String getIndexFieldTag() { + return "tag"; + } + + @Override + public String getIndexFieldUrl() { + return "url"; + } + + @Override + public String getIndexFieldLang() { + return "lang"; + } + + @Override + public String getIndexFieldId() { + return "_id"; + } + + @Override + public String getIndexDocumentUpdateIndex() { + return "fess.update"; + } + }); + final LanguageHelper languageHelper = new LanguageHelper() { + { + langFields = new String[] { "content" }; + } + }; + ComponentUtil.register(languageHelper, "languageHelper"); + ComponentUtil.register(new SearchEngineClient() { + @Override + public long scrollSearch(final String index, final SearchCondition condition, + final BooleanFunction> cursor) { + final SearchRequestBuilder builder = new SearchRequestBuilder(null, SearchAction.INSTANCE); + condition.build(builder); + final String[] includes = builder.request().source().fetchSource().includes(); + assertEquals(Set.of("url", "lang", "tag"), Set.of(includes)); + searchedIndices.add(index); + for (final Map doc : docList) { + if (!cursor.apply(new HashMap<>(doc))) { + break; + } + } + return docList.size(); + } + }, "searchEngineClient"); + ComponentUtil.register(new SearchHelper() { + @Override + public boolean bulkUpdate(final Consumer consumer) { + final BulkRequestBuilder builder = new BulkRequestBuilder(null, BulkAction.INSTANCE); + consumer.accept(builder); + if (failBulk) { + throw new IllegalStateException("bulk failed"); + } + for (final DocWriteRequest request : builder.request().requests()) { + updates.add((UpdateRequest) request); + } + return true; + } + }, "searchHelper"); + ComponentUtil.register(new TagTypeHelper() { + @Override + public Map> findTagValuesByUrls(final Collection urls) { + lookups.add(new ArrayList<>(urls)); + final Map> result = new HashMap<>(); + for (final String url : urls) { + if (tagMap.containsKey(url)) { + result.put(url, tagMap.get(url)); + } + } + return result; + } + }, "tagTypeHelper"); + } + + private void addDoc(final String id, final String url, final Object tag, final String lang) { + final Map doc = new HashMap<>(); + doc.put("_id", id); + doc.put("url", url); + if (tag != null) { + doc.put("tag", tag); + } + if (lang != null) { + doc.put("lang", lang); + } + docList.add(doc); + } + + private static Object scriptField(final Script script, final String name) { + try { + final java.lang.reflect.Field field = Script.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(script); + } catch (final ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } + + @SuppressWarnings("unchecked") + private static Map paramsOf(final Script script) { + return (Map) scriptField(script, "params"); + } + + private UpdateRequest updateOf(final String id) { + return updates.stream().filter(r -> id.equals(r.id())).findFirst().orElse(null); + } + + @Test + public void test_execute_setsAndRemovesTags() { + tagMap.put("http://a/", new LinkedHashSet<>(List.of("v1", "v2"))); + tagMap.put("http://c/", new LinkedHashSet<>(List.of("v3"))); + tagMap.put("http://d/", new LinkedHashSet<>(List.of("v4"))); + addDoc("d1", "http://a/", null, "ja"); // gains v1, v2 + addDoc("d2", "http://b/", "old", null); // loses its tag + addDoc("d3", "http://c/", List.of("v3"), null); // unchanged + addDoc("d4", "http://d/", "v4", null); // unchanged, single string + addDoc("d5", "http://e/", null, null); // no tag, none wanted + addDoc("d6", "http://c/", List.of("v3", "gone"), null); // drops gone + + final String result = new TagUpdaterJob().execute(); + + assertEquals(List.of("fess.update"), searchedIndices); + // one lookup per page of user.tag.process.batch.size documents + assertEquals(List.of(List.of("http://a/", "http://b/"), List.of("http://c/", "http://d/"), List.of("http://e/", "http://c/")), + lookups); + assertEquals(3, updates.size()); + + final UpdateRequest d1 = updateOf("d1"); + assertEquals("fess.update", d1.index()); + assertEquals(List.of("v1", "v2"), paramsOf(d1.script()).get("tag")); + assertTrue(d1.script().getIdOrCode().startsWith("ctx._source.tag=params.tag;")); + assertTrue(d1.script().getIdOrCode().endsWith(";ctx._source.content_ja=ctx._source.content")); + assertTrue(d1.retryOnConflict() > 0); + + final UpdateRequest d2 = updateOf("d2"); + assertEquals("ctx._source.remove('tag')", d2.script().getIdOrCode()); + assertTrue(d2.retryOnConflict() > 0); + + assertEquals(List.of("v3"), paramsOf(updateOf("d6").script()).get("tag")); + assertNull(updateOf("d3")); + assertNull(updateOf("d4")); + assertNull(updateOf("d5")); + + assertTrue(result.contains("3 documents updated"), result); + assertTrue(result.contains("6 documents checked"), result); + } + + @Test + public void test_execute_bulkFailureContinues() { + addDoc("d1", "http://b/", "old", null); + addDoc("d2", "http://b/", "old", null); + addDoc("d3", "http://b/", "old", null); + failBulk = true; + + final String result = new TagUpdaterJob().execute(); + + assertEquals(2, lookups.size()); + assertTrue(result.contains("0 documents updated"), result); + assertTrue(result.contains("3 failed"), result); + } + + @Test + public void test_execute_disabled() { + userTagEnabled = false; + addDoc("d1", "http://b/", "old", null); + + final String result = new TagUpdaterJob().execute(); + + assertTrue(searchedIndices.isEmpty()); + assertTrue(updates.isEmpty()); + assertTrue(result.contains("disabled"), result); + } +} diff --git a/src/test/java/org/codelibs/fess/script/javascript/ShippedScriptCompilationTest.java b/src/test/java/org/codelibs/fess/script/javascript/ShippedScriptCompilationTest.java index 9fdbfaf91a..9cb7bf633b 100644 --- a/src/test/java/org/codelibs/fess/script/javascript/ShippedScriptCompilationTest.java +++ b/src/test/java/org/codelibs/fess/script/javascript/ShippedScriptCompilationTest.java @@ -66,7 +66,7 @@ public void test_shippedScheduledJobsCompile() throws Exception { } } } - assertEquals(14, scripts.size()); + assertEquals(15, scripts.size()); for (final String script : scripts) { assertCompiles(script); } From 76deee2d8612f1e7005b76649ed2d63928739d75 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:46:45 +0900 Subject: [PATCH 06/14] fix(tagtype): refresh the update index before tag updates by query A DELETE or RENAME finds its documents by searching the tag field, but neither the bulk update nor updateByQuery refreshes the index, so documents written earlier in the same queue cycle were not found: an ADD followed by a DELETE left the tag on the document, a rename chain A to B to C stopped at B, and an ADD followed by a RENAME kept the old value. processQueue now remembers whether it wrote to the update index since the last refresh and, when it did, refreshes that index synchronously before each DELETE or RENAME. A failed refresh is logged and the update still runs. The WARN for a failed updateByQuery now also reports how many documents it had visited. --- .../codelibs/fess/helper/TagTypeHelper.java | 52 +++++++-- .../fess/helper/TagTypeHelperQueueTest.java | 100 +++++++++++++++++- 2 files changed, 141 insertions(+), 11 deletions(-) diff --git a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java index ca0f958690..4a87f0ef79 100644 --- a/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java +++ b/src/main/java/org/codelibs/fess/helper/TagTypeHelper.java @@ -38,6 +38,7 @@ import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; import java.util.regex.Pattern; import org.apache.logging.log4j.LogManager; @@ -94,6 +95,12 @@ public class TagTypeHelper { /** The number of changes in {@link #tagChangeQueue}. */ protected final AtomicInteger tagChangeQueueSize = new AtomicInteger(); + /** + * Whether {@link #processQueue()} wrote to the update index since its last refresh. A search + * by tag must not run before such writes are visible. + */ + protected boolean updateIndexDirty; + /** * Default constructor. */ @@ -469,6 +476,7 @@ public synchronized int processQueue() { return 0; } final Map> pendingMap = new LinkedHashMap<>(); + updateIndexDirty = false; for (final TagChange tagChange : changeList) { switch (tagChange.type()) { case ADD, REMOVE -> { @@ -532,6 +540,7 @@ protected void flushTagChanges(final Map> pendingMa }); documents = requestList.size(); if (!requestList.isEmpty()) { + updateIndexDirty = true; ComponentUtil.getSearchHelper().bulkUpdate(builder -> requestList.forEach(builder::add)); } } catch (final Exception e) { @@ -543,7 +552,8 @@ protected void flushTagChanges(final Map> pendingMa } /** - * Updates every document holding a tag value. A failure is logged. + * Updates every document holding a tag value. The update index is refreshed first when this + * cycle wrote to it, so that documents just given the value are found. A failure is logged. * * @param value the tag value the documents hold * @param addList the values to put on the documents @@ -554,23 +564,49 @@ protected void updateDocumentsWithTag(final String value, final List add return; } final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final String index = fessConfig.getIndexDocumentUpdateIndex(); + if (updateIndexDirty) { + try { + refreshUpdateIndex(index); + updateIndexDirty = false; + } catch (final Exception e) { + logger.warn("Failed to refresh the index before updating the user tag of documents: index={}", index, e); + } + } + final AtomicLong documents = new AtomicLong(); try { final long count = ComponentUtil.getSearchEngineClient() - .updateByQuery(fessConfig.getIndexDocumentUpdateIndex(), - builder -> builder.setQuery(QueryBuilders.termQuery(fessConfig.getIndexFieldTag(), value)) - .setFetchSource(new String[] { fessConfig.getIndexFieldLang() }, null), - (builder, hit) -> builder.setScript(createUpdateTagScript(hit.getSourceAsMap(), addList, removeList)) - .setRetryOnConflict(RETRY_ON_CONFLICT)); + .updateByQuery(index, builder -> builder.setQuery(QueryBuilders.termQuery(fessConfig.getIndexFieldTag(), value)) + .setFetchSource(new String[] { fessConfig.getIndexFieldLang() }, null), (builder, hit) -> { + documents.incrementAndGet(); + updateIndexDirty = true; + return builder.setScript(createUpdateTagScript(hit.getSourceAsMap(), addList, removeList)) + .setRetryOnConflict(RETRY_ON_CONFLICT); + }); if (logger.isDebugEnabled()) { logger.debug("Updated the user tag of documents: added={}, removed={}, documents={}", addList.size(), removeList.size(), count); } } catch (final Exception e) { - logger.warn("Failed to update the user tag of documents. Run tag_updater to restore them: added={}, removed={}", addList.size(), - removeList.size(), e); + logger.warn("Failed to update the user tag of documents. Run tag_updater to restore them: added={}, removed={}, documents={}", + addList.size(), removeList.size(), documents.get(), e); } } + /** + * Refreshes an index and waits for it, making the writes before it visible to searches. + * + * @param index the index + */ + protected void refreshUpdateIndex(final String index) { + ComponentUtil.getSearchEngineClient() + .admin() + .indices() + .prepareRefresh(index) + .execute() + .actionGet(ComponentUtil.getFessConfig().getIndexIndicesTimeout()); + } + /** * Builds the painless code that puts the values of {@code params.add} on a document and takes * those of {@code params.remove} off it. The field may be missing, a single value or a list; a diff --git a/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java b/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java index 1f454d0f20..73e20ad960 100644 --- a/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java +++ b/src/test/java/org/codelibs/fess/helper/TagTypeHelperQueueTest.java @@ -69,6 +69,11 @@ public class TagTypeHelperQueueTest extends UnitFessTestCase { private int queueMaxSize; + /** Tag values that no document holds, so updateByQuery visits no document. */ + private List unusedValues; + + private boolean failRefresh; + private int batchSize; @Override @@ -80,6 +85,8 @@ protected void setUp(final TestInfo testInfo) throws Exception { updateByQueryRequests = new ArrayList<>(); failingBulkCalls = new ArrayList<>(); queueMaxSize = 10000; + unusedValues = new ArrayList<>(); + failRefresh = false; batchSize = 100; ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { private static final long serialVersionUID = 1L; @@ -153,6 +160,9 @@ public long updateByQuery(final String index, final Function consumer) { return true; } }, "searchHelper"); - tagTypeHelper = new TagTypeHelper(); + tagTypeHelper = new TagTypeHelper() { + @Override + protected void refreshUpdateIndex(final String index) { + events.add("refresh:" + index); + if (failRefresh) { + throw new IllegalStateException("refresh failed"); + } + } + }; } private void addDoc(final String id, final String url, final String lang) { @@ -242,8 +260,8 @@ public void test_processQueue_deleteThenAdd() { assertEquals(3, tagTypeHelper.processQueue()); - assertEquals(List.of("search:fess.update:[http://example.com/a]", "bulk:1", "updateByQuery:fess.update:tag=v", - "search:fess.update:[http://example.com/a]", "bulk:1"), events); + assertEquals(List.of("search:fess.update:[http://example.com/a]", "bulk:1", "refresh:fess.update", + "updateByQuery:fess.update:tag=v", "search:fess.update:[http://example.com/a]", "bulk:1"), events); assertEquals(List.of("v"), param(bulkCalls.get(0).get(0), "remove")); final UpdateRequest deleteRequest = updateByQueryRequests.get(0); assertEquals(List.of(), param(deleteRequest, "add")); @@ -378,4 +396,80 @@ public void test_processQueue_concurrentEnqueue() throws Exception { processed += tagTypeHelper.processQueue(); assertEquals(50, processed); } + + @Test + public void test_processQueue_addThenDelete_refreshesFirst() { + addDoc("d1", "http://example.com/a", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("v"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals( + List.of("search:fess.update:[http://example.com/a]", "bulk:1", "refresh:fess.update", "updateByQuery:fess.update:tag=v"), + events); + } + + @Test + public void test_processQueue_renameChain_refreshesBetween() { + assertTrue(tagTypeHelper.enqueue(TagChange.rename("A", "B"))); + assertTrue(tagTypeHelper.enqueue(TagChange.rename("B", "C"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals(List.of("updateByQuery:fess.update:tag=A", "refresh:fess.update", "updateByQuery:fess.update:tag=B"), events); + assertEquals(List.of("C"), param(updateByQueryRequests.get(1), "add")); + assertEquals(List.of("B"), param(updateByQueryRequests.get(1), "remove")); + } + + @Test + public void test_processQueue_addThenRename_refreshesFirst() { + addDoc("d1", "http://example.com/a", null); + assertTrue(tagTypeHelper.enqueue(TagChange.add("old", "http://example.com/a"))); + assertTrue(tagTypeHelper.enqueue(TagChange.rename("old", "new"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals( + List.of("search:fess.update:[http://example.com/a]", "bulk:1", "refresh:fess.update", "updateByQuery:fess.update:tag=old"), + events); + } + + @Test + public void test_processQueue_noWrite_skipsRefresh() { + // the URL has no document, so nothing is written before the deletions + assertTrue(tagTypeHelper.enqueue(TagChange.add("v", "http://example.com/none"))); + unusedValues.add("v"); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("v"))); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("w"))); + + assertEquals(3, tagTypeHelper.processQueue()); + + assertEquals(List.of("search:fess.update:[http://example.com/none]", "updateByQuery:fess.update:tag=v", + "updateByQuery:fess.update:tag=w"), events); + } + + @Test + public void test_processQueue_refreshOncePerWrite() { + assertTrue(tagTypeHelper.enqueue(TagChange.rename("A", "B"))); + unusedValues.add("B"); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("B"))); + assertTrue(tagTypeHelper.enqueue(TagChange.delete("C"))); + + tagTypeHelper.processQueue(); + + assertEquals(List.of("updateByQuery:fess.update:tag=A", "refresh:fess.update", "updateByQuery:fess.update:tag=B", + "updateByQuery:fess.update:tag=C"), events); + } + + @Test + public void test_processQueue_refreshFailureContinues() { + failRefresh = true; + assertTrue(tagTypeHelper.enqueue(TagChange.rename("A", "B"))); + assertTrue(tagTypeHelper.enqueue(TagChange.rename("B", "C"))); + + assertEquals(2, tagTypeHelper.processQueue()); + + assertEquals(List.of("updateByQuery:fess.update:tag=A", "refresh:fess.update", "updateByQuery:fess.update:tag=B"), events); + } } From 0d9ed7fee751bac77f73a5e46db3a527f43eb75d Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 19:52:01 +0900 Subject: [PATCH 07/14] feat(tagtype): set the tag field from tag_type paths when documents are indexed Apply tags in IndexingHelper.sendDocuments (only when user.tag.enabled), the bulk document API and the admin/API searchlist create and update paths. The tag field is system-managed and is stripped from client-supplied documents. --- .../searchlist/AdminSearchlistAction.java | 17 ++-- .../documents/ApiAdminDocumentsAction.java | 1 + .../searchlist/ApiAdminSearchlistAction.java | 2 + .../codelibs/fess/helper/IndexingHelper.java | 3 + .../searchlist/AdminSearchlistActionTest.java | 85 +++++++++++++++++++ .../ApiAdminDocumentsActionTest.java | 28 ++++++ .../ApiAdminSearchlistActionTest.java | 11 +++ .../fess/helper/IndexingHelperTest.java | 71 ++++++++++++++++ 8 files changed, 212 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistAction.java b/src/main/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistAction.java index edc46ea8dc..23d3768e56 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistAction.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistAction.java @@ -87,18 +87,20 @@ public AdminSearchlistAction() { * client-supplied doc map (server-side, regardless of transport) by {@link #stripSystemManagedFields} * before it is overlaid onto the fetched entity in {@link #create}/{@link #update}. */ - private static final Set SYSTEM_MANAGED_FIELDS = - Set.of(Constants.CONTENT_CHUNK_VECTOR_FIELD, Constants.CONTENT_CHUNK_STATUS_FIELD); + private static Set systemManagedFields() { + return Set.of(Constants.CONTENT_CHUNK_VECTOR_FIELD, Constants.CONTENT_CHUNK_STATUS_FIELD, + ComponentUtil.getFessConfig().getIndexFieldTag()); + } /** - * Strips {@link #SYSTEM_MANAGED_FIELDS} (and, when the FETCHED entity's existing {@code content} + * Strips {@link #systemManagedFields()} (and, when the FETCHED entity's existing {@code content} * value is a {@code List} -- i.e. an already-chunked document -- the {@code content} key too) from * a client-supplied doc map, in place, before it is overlaid onto the freshly-fetched entity via * {@code entity.putAll(...)}. * *

    {@link #registerContentReadOnly} only renders the {@code content} textarea as {@code disabled} * for an already-chunked document, and the editable-field walk in {@link #registerExtraFields} only - * hides {@link #SYSTEM_MANAGED_FIELDS} from that same rendering pass -- neither stops a direct POST + * hides {@link #systemManagedFields()} from that same rendering pass -- neither stops a direct POST * or API call that includes these keys in the submitted map, since a disabled/hidden form control is * purely a client-side rendering convention. This method is the server-side enforcement point, * shared by this action's {@link #create}/{@link #update} and {@code ApiAdminSearchlistAction}'s @@ -113,7 +115,7 @@ public static void stripSystemManagedFields(final Map entity, fi if (doc == null) { return; } - SYSTEM_MANAGED_FIELDS.forEach(doc::remove); + systemManagedFields().forEach(doc::remove); if (entity != null && entity.get("content") instanceof List) { doc.remove("content"); } @@ -431,6 +433,7 @@ public HtmlResponse create(final CreateForm form) { entity.put(fessConfig.getIndexFieldId(), newId); final String index = fessConfig.getIndexDocumentUpdateIndex(); + ComponentUtil.getTagTypeHelper().applyTags(List.of(entity)); searchEngineClient.store(index, entity); saveInfo(messages -> messages.addSuccessCrudCreateCrudTable(GLOBAL)); } catch (final Exception e) { @@ -476,6 +479,7 @@ public HtmlResponse update(final EditForm form) { } } + ComponentUtil.getTagTypeHelper().applyTags(List.of(entity)); searchEngineClient.store(index, entity); saveInfo(messages -> messages.addSuccessCrudUpdateCrudTable(GLOBAL)); } catch (final Exception e) { @@ -616,10 +620,11 @@ private void registerExtraFields(final RenderData data) { registerContentReadOnly(data, doc); + final Set systemManagedFields = systemManagedFields(); final List extraFieldNames = new ArrayList<>(); final Map extraFieldTypes = new TreeMap<>(); candidateFields.stream() - .filter(key -> !STANDARD_EDIT_FIELDS.contains(key) && !reservedFields.contains(key) && !SYSTEM_MANAGED_FIELDS.contains(key)) + .filter(key -> !STANDARD_EDIT_FIELDS.contains(key) && !reservedFields.contains(key) && !systemManagedFields.contains(key)) .forEach(key -> { final String type; if (arrayFieldSet.contains(key)) { diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsAction.java b/src/main/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsAction.java index f5c9bbdf20..ddd378568a 100644 --- a/src/main/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsAction.java +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsAction.java @@ -169,6 +169,7 @@ public ApiAdminDocumentsAction() { }); } + ComponentUtil.getTagTypeHelper().applyTags(docList); final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper(); final BulkResponse response = searchEngineClient.addAll(fessConfig.getIndexDocumentUpdateIndex(), docList, (doc, builder) -> { if (doc.get(fessConfig.getIndexFieldConfigId()) instanceof final String configId) { diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistAction.java b/src/main/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistAction.java index aa3b5fa95c..5f41b9497e 100644 --- a/src/main/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistAction.java +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistAction.java @@ -171,6 +171,7 @@ public JsonResponse docs(final SearchBody body) { entity.put(fessConfig.getIndexFieldId(), newId); final String index = fessConfig.getIndexDocumentUpdateIndex(); + ComponentUtil.getTagTypeHelper().applyTags(List.of(entity)); searchEngineClient.store(index, entity); saveInfo(messages -> messages.addSuccessCrudCreateCrudTable(GLOBAL)); } catch (final Exception e) { @@ -221,6 +222,7 @@ public JsonResponse docs(final SearchBody body) { } } + ComponentUtil.getTagTypeHelper().applyTags(List.of(entity)); searchEngineClient.store(index, entity); saveInfo(messages -> messages.addSuccessCrudUpdateCrudTable(GLOBAL)); } catch (final Exception e) { diff --git a/src/main/java/org/codelibs/fess/helper/IndexingHelper.java b/src/main/java/org/codelibs/fess/helper/IndexingHelper.java index 97970acb3f..71f4673116 100644 --- a/src/main/java/org/codelibs/fess/helper/IndexingHelper.java +++ b/src/main/java/org/codelibs/fess/helper/IndexingHelper.java @@ -102,6 +102,9 @@ public void sendDocuments(final SearchEngineClient searchEngineClient, final Doc } }); } + if (fessConfig.isUserTagEnabled()) { + ComponentUtil.getTagTypeHelper().applyTags(docList); + } final CrawlingConfigHelper crawlingConfigHelper = ComponentUtil.getCrawlingConfigHelper(); synchronized (searchEngineClient) { final long deletedDocCount = deleteOldDocuments(searchEngineClient, docList); diff --git a/src/test/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistActionTest.java b/src/test/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistActionTest.java index 1bba1a3c85..17af692277 100644 --- a/src/test/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistActionTest.java +++ b/src/test/java/org/codelibs/fess/app/web/admin/searchlist/AdminSearchlistActionTest.java @@ -49,6 +49,11 @@ protected void setUp(TestInfo testInfo) throws Exception { private static class TestFessConfig extends FessConfig.SimpleImpl { private static final long serialVersionUID = 1L; + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public Integer getPageSearchlistContentMaxLengthAsInteger() { // SimpleImpl's ObjectiveConfig backing is never initialized in unit tests (a plain @@ -1204,6 +1209,78 @@ public void test_update_alreadyChunkedDocument_stripsSystemFieldsAndPreservesChu "the fetched entity's own vector must survive; the client's smuggled empty list must be dropped"); } + @Test + public void test_stripSystemManagedFields_removesTagField() { + final Map doc = new HashMap<>(); + doc.put("url", "https://example.com"); + doc.put("tag", new String[] { "smuggled" }); + AdminSearchlistAction.stripSystemManagedFields(new HashMap<>(), doc); + assertFalse(doc.containsKey("tag")); + } + + @Test + public void test_create_setsTagFromTagTypes() throws Exception { + final FessConfig testConfig = buildFullFessConfig(); + final FakeSearchEngineClient client = new FakeSearchEngineClient(); + ComponentUtil.register(new org.codelibs.fess.helper.TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + docList.forEach(d -> d.put("tag", new String[] { "tv" })); + } + }, "tagTypeHelper"); + final AdminSearchlistAction action = createInjectedAction(client, testConfig); + mockTokenRequested(action.getClass()); + + final CreateForm form = new CreateForm(); + form.crudMode = CrudMode.CREATE; + form.doc = new HashMap<>(); + form.doc.put("url", "https://example.com/new"); + form.doc.put("title", "Title"); + form.doc.put("role", "Rguest"); + form.doc.put("boost", "1.0"); + form.doc.put("tag", new String[] { "smuggled" }); + action.create(form); + + assertNotNull(client.lastStoredDoc); + assertEquals("tv", ((String[]) client.lastStoredDoc.get("tag"))[0]); + } + + @Test + public void test_update_recomputesTagFromTagTypes() throws Exception { + final FessConfig testConfig = buildFullFessConfig(); + final FakeSearchEngineClient client = new FakeSearchEngineClient(); + final Map fetched = new HashMap<>(); + fetched.put("doc_id", "d1"); + fetched.put("_id", "d1-id"); + fetched.put("url", "https://example.com/a"); + fetched.put("tag", new String[] { "old" }); + fetched.put("_seq_no", 5L); + fetched.put("_primary_term", 1L); + client.documentToReturn = fetched; + ComponentUtil.register(new org.codelibs.fess.helper.TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + docList.forEach(d -> d.put("tag", new String[] { "new:" + d.get("url") })); + } + }, "tagTypeHelper"); + final AdminSearchlistAction action = createInjectedAction(client, testConfig); + mockTokenRequested(action.getClass()); + + final EditForm form = new EditForm(); + form.crudMode = CrudMode.EDIT; + form.doc = new HashMap<>(); + form.doc.put("doc_id", "d1"); + form.doc.put("url", "https://example.com/b"); + form.doc.put("title", "Title"); + form.doc.put("role", "Rguest"); + form.doc.put("boost", "1.0"); + form.q = "test-query"; + action.update(form); + + assertNotNull(client.lastStoredDoc); + assertEquals("new:https://example.com/b", ((String[]) client.lastStoredDoc.get("tag"))[0]); + } + @Test public void test_create_freshEntity_stripsSystemFieldsButKeepsClientContentString() throws Exception { final FessConfig testConfig = buildFullFessConfig(); @@ -1251,6 +1328,14 @@ public void test_create_freshEntity_stripsSystemFieldsButKeepsClientContentStrin */ private AdminSearchlistAction createInjectedAction(final FakeSearchEngineClient client, final FessConfig testConfig) throws Exception { suppressBindingOf(org.codelibs.fess.app.web.base.login.FessLoginAssist.class); + if (!ComponentUtil.hasComponent("tagTypeHelper")) { + ComponentUtil.register(new org.codelibs.fess.helper.TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + // no-op + } + }, "tagTypeHelper"); + } final AdminSearchlistAction action = new AdminSearchlistAction(); inject(action); diff --git a/src/test/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsActionTest.java b/src/test/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsActionTest.java index 81148bdb4a..9a4f57f7be 100644 --- a/src/test/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsActionTest.java +++ b/src/test/java/org/codelibs/fess/app/web/api/admin/documents/ApiAdminDocumentsActionTest.java @@ -43,6 +43,23 @@ public class ApiAdminDocumentsActionTest extends UnitFessTestCase { // put$bulk system-managed field stripping (Fix 2) // ================================================ + @Test + public void test_put$bulk_appliesTagsBeforeIndexing() throws Exception { + final CapturingSearchEngineClient client = new CapturingSearchEngineClient(); + final ApiAdminDocumentsAction action = createInjectedAction(client, buildFullFessConfig()); + final Map doc = new HashMap<>(); + doc.put("url", "https://example.com/doc"); + doc.put("title", "Title"); + doc.put("role", "Rguest"); + doc.put("boost", "1.0"); + final BulkBody body = new BulkBody(); + body.documents = new ArrayList<>(); + body.documents.add(doc); + action.put$bulk(body); + org.junit.jupiter.api.Assertions.assertEquals(1, client.capturedDocs.size()); + org.junit.jupiter.api.Assertions.assertEquals("tv", ((String[]) client.capturedDocs.get(0).get("tag"))[0]); + } + @Test public void test_put$bulk_stripsSystemManagedFieldsBeforeIndexing() throws Exception { final FessConfig testConfig = buildFullFessConfig(); @@ -99,6 +116,12 @@ public class ApiAdminDocumentsActionTest extends UnitFessTestCase { */ private ApiAdminDocumentsAction createInjectedAction(final SearchEngineClient client, final FessConfig testConfig) throws Exception { suppressBindingOf(org.codelibs.fess.app.web.base.login.FessLoginAssist.class); + ComponentUtil.register(new org.codelibs.fess.helper.TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + docList.forEach(d -> d.put("tag", new String[] { "tv" })); + } + }, "tagTypeHelper"); // The API action declares an @Resource AccessTokenService whose own AccessTokenBhv field // cannot bind without a live datastore; put$bulk never uses it, so suppress its binding. suppressBindingOf(org.codelibs.fess.app.service.AccessTokenService.class); @@ -165,6 +188,11 @@ private FessConfig buildFullFessConfig() { return new FessConfig.SimpleImpl() { private static final long serialVersionUID = 1L; + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexAdminRequiredFields() { return "url,title,role,boost"; diff --git a/src/test/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistActionTest.java b/src/test/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistActionTest.java index 64dba180eb..6988be8548 100644 --- a/src/test/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistActionTest.java +++ b/src/test/java/org/codelibs/fess/app/web/api/admin/searchlist/ApiAdminSearchlistActionTest.java @@ -214,6 +214,12 @@ private static EditBody newEditBody() { private ApiAdminSearchlistAction createInjectedAction(final FakeSearchEngineClient client, final FessConfig testConfig) throws Exception { suppressBindingOf(org.codelibs.fess.app.web.base.login.FessLoginAssist.class); + ComponentUtil.register(new org.codelibs.fess.helper.TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + docList.forEach(d -> d.put("tag", new String[] { "tv" })); + } + }, "tagTypeHelper"); // FessApiAction declares @Resource AccessTokenService, whose own AccessTokenBhv @Resource // cannot be assembled in the unit container; only isAccessAllowed() (never reached when the // execute method is called directly) uses it. @@ -253,6 +259,11 @@ private FessConfig buildFullFessConfig() { return new FessConfig.SimpleImpl() { private static final long serialVersionUID = 1L; + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexAdminRequiredFields() { return "url,title,role,boost"; diff --git a/src/test/java/org/codelibs/fess/helper/IndexingHelperTest.java b/src/test/java/org/codelibs/fess/helper/IndexingHelperTest.java index a100db8dc2..a71edcccb1 100644 --- a/src/test/java/org/codelibs/fess/helper/IndexingHelperTest.java +++ b/src/test/java/org/codelibs/fess/helper/IndexingHelperTest.java @@ -130,6 +130,77 @@ public List> getDocumentList(final String index, final Searc assertEquals("fess.update", sentIndex.get()); } + @Test + public void test_sendDocuments_appliesTagsBeforeAddAll() { + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return true; + } + + @Override + public boolean isThumbnailCrawlerEnabled() { + return false; + } + + @Override + public String getIndexDocumentUpdateIndex() { + return "fess.update"; + } + + @Override + public String getIndexFieldConfigId() { + return "config_id"; + } + + @Override + public String getIndexFieldUrl() { + return "url"; + } + + @Override + public String getIndexFieldId() { + return "_id"; + } + + @Override + public String getIndexFieldDocId() { + return "doc_id"; + } + }); + final List> applied = new ArrayList<>(); + ComponentUtil.register(new TagTypeHelper() { + @Override + public void applyTags(final List> docList) { + applied.addAll(docList); + docList.forEach(d -> d.put("tag", new String[] { "t1" })); + } + }, "tagTypeHelper"); + final List tagsAtAddAll = new ArrayList<>(); + final SearchEngineClient client = new SearchEngineClient() { + @Override + public BulkResponse addAll(final String index, final List> docList, + final BiConsumer, IndexRequestBuilder> options) { + docList.forEach(d -> tagsAtAddAll.add(d.get("tag"))); + return new BulkResponse(new BulkItemResponse[0], 0L); + } + + @Override + public List> getDocumentList(final String index, final SearchCondition condition) { + return Collections.emptyList(); + } + }; + ComponentUtil.register(client, "searchEngineClient"); + final DocList docList = new DocList(); + docList.add(new HashMap<>(Map.of("_id", "001", "url", "http://test.com/001"))); + indexingHelper.sendDocuments(client, docList); + assertEquals(1, applied.size()); + assertEquals(1, tagsAtAddAll.size()); + assertNotNull(tagsAtAddAll.get(0)); + } + @Test public void test_deleteOldDocuments() { documentSizeByQuery = 0L; From 5a33f89895d254c7832e795b0e14161b1dfb8dd1 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 20:01:32 +0900 Subject: [PATCH 08/14] feat(search): restrict tag conditions and the tag facet to the caller's visible tags A value of the tag field decodes to the name and the owner of a tag, and the field of a document holds every tag put on it, so a condition or a facet on the field must only ever reveal the tags the caller can see. The tag field is a search field, a facet field and a not-analyzed field again, but not a response field: no response returns raw tag values. A condition on the tag field (fields.tag, tag: in the query, ex_q and facet.query, in the keyword leg and the vector leg alike) is a term query only for the exact value of a tag that TagTypeHelper#getVisibleTagTypes reports as visible. A value the caller cannot see, and a wildcard, prefix, fuzzy or range condition on the field, match no document. Each value is resolved once per query context. A caller who is not logged in, or any caller while user.tag.enabled is false, matches nothing. The admin search is not restricted. The tag facet includes only the exact values of the tags that TagTypeHelper#getVisibleTagValues returns, so tags of the same name of two owners are two buckets and invisible tags never push visible ones out of the top buckets. When the caller can see no tag, or user tags are disabled, the tag facet is not aggregated. The admin search is not restricted. SemanticSearchRequestParams now delegates getResponseFields, so a request that adds a field to its response gets it on hits of the vector leg too. --- .../codelibs/fess/entity/QueryContext.java | 15 + .../opensearch/client/SearchEngineClient.java | 43 +++ .../fess/query/FuzzyQueryCommand.java | 5 + .../fess/query/PhraseQueryCommand.java | 9 + .../fess/query/PrefixQueryCommand.java | 5 + .../org/codelibs/fess/query/QueryCommand.java | 73 ++++ .../codelibs/fess/query/QueryFieldConfig.java | 3 + .../codelibs/fess/query/TermQueryCommand.java | 3 + .../fess/query/TermRangeQueryCommand.java | 5 + .../fess/query/WildcardQueryCommand.java | 4 + .../rank/fusion/SemanticChunkSearcher.java | 5 + .../SearchEngineClientFacetFieldTest.java | 118 +++++- .../fess/query/MatchAllQueryCommandTest.java | 5 + .../codelibs/fess/query/QueryCommandTest.java | 5 + .../QueryFieldConfigSetBasedLookupTest.java | 5 + .../fess/query/QueryFieldConfigTest.java | 24 ++ .../codelibs/fess/query/QueryTestBase.java | 13 + .../fess/query/TagQueryRestrictionTest.java | 352 ++++++++++++++++++ .../fess/query/TermQueryCommandTest.java | 5 + .../fusion/SemanticChunkSearcherTest.java | 14 + 20 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java diff --git a/src/main/java/org/codelibs/fess/entity/QueryContext.java b/src/main/java/org/codelibs/fess/entity/QueryContext.java index 367173e86a..717dc1af0e 100644 --- a/src/main/java/org/codelibs/fess/entity/QueryContext.java +++ b/src/main/java/org/codelibs/fess/entity/QueryContext.java @@ -74,6 +74,9 @@ public class QueryContext { /** The type of the search request, or null when the query is not built for a search request. */ protected SearchRequestType searchRequestType = null; + /** Whether the caller can see each tag value used in a condition, resolved on first use. */ + protected Map tagVisibilityMap = null; + /** * Constructs a new QueryContext with the specified query string. * Processes special query prefixes (allinurl:, allintitle:) and initializes @@ -288,4 +291,16 @@ public SearchRequestType getSearchRequestType() { public void setSearchRequestType(final SearchRequestType searchRequestType) { this.searchRequestType = searchRequestType; } + + /** + * Gets whether the caller can see each tag value that a condition of this query has used so far. + * A value is resolved once per query, however many conditions use it. + * @return The visibility of each resolved tag value, never null. + */ + public Map getTagVisibilityMap() { + if (tagVisibilityMap == null) { + tagVisibilityMap = new HashMap<>(); + } + return tagVisibilityMap; + } } diff --git a/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java b/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java index 6649b6a15d..89e776f095 100644 --- a/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java +++ b/src/main/java/org/codelibs/fess/opensearch/client/SearchEngineClient.java @@ -35,6 +35,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Function; @@ -44,6 +46,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.lucene.util.BytesRef; import org.codelibs.core.beans.util.BeanUtil; import org.codelibs.core.exception.ResourceNotFoundRuntimeException; import org.codelibs.core.io.FileUtil; @@ -166,6 +169,7 @@ import org.codelibs.fesen.opensearch.search.SearchHits; import org.codelibs.fesen.opensearch.search.aggregations.AggregationBuilders; import org.codelibs.fesen.opensearch.search.aggregations.bucket.filter.FilterAggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.IncludeExclude; import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; import org.codelibs.fesen.opensearch.search.builder.PointInTimeBuilder; import org.codelibs.fesen.opensearch.search.collapse.CollapseBuilder; @@ -2732,9 +2736,22 @@ protected void buildFacet(final QueryHelper queryHelper, final QueryFieldConfig messages -> messages.addErrorsInvalidQueryUnsupportedFacetField(UserMessages.GLOBAL_PROPERTY_KEY, f), "Unsupported facet field: " + f); } + final IncludeExclude tagInclude; + if (f.equals(fessConfig.getIndexFieldTag()) && !SearchRequestType.ADMIN_SEARCH.equals(searchRequestType)) { + // a tag value decodes to the name and the owner of a tag: only the tags the caller can see become buckets + tagInclude = buildVisibleTagInclude(fessConfig); + if (tagInclude == null) { + return; + } + } else { + tagInclude = null; + } final String encodedField = BaseEncoding.base64().encode(f.getBytes(StandardCharsets.UTF_8)); final TermsAggregationBuilder termsBuilder = AggregationBuilders.terms(Constants.FACET_FIELD_PREFIX + encodedField).field(f); + if (tagInclude != null) { + termsBuilder.includeExclude(tagInclude); + } termsBuilder.order(facetInfo.getBucketOrder()); if (facetInfo.size != null) { final int maxFacetSize = fessConfig.getQueryFacetFieldsSizeMaxOrDefault(); @@ -2760,6 +2777,32 @@ protected void buildFacet(final QueryHelper queryHelper, final QueryFieldConfig })); } + /** + * Builds the include of the tag facet: the exact values of the tags that the caller can see. + * + * @param fessConfig the Fess configuration + * @return the include, or null when the caller can see no tag and the facet is not aggregated + */ + protected IncludeExclude buildVisibleTagInclude(final FessConfig fessConfig) { + if (!fessConfig.isUserTagEnabled()) { + return null; + } + final Set values; + try { + values = ComponentUtil.getTagTypeHelper().getVisibleTagValues(searchRequestType); + } catch (final RuntimeException e) { + logger.warn("Failed to resolve the visible tags; the tag facet is not aggregated. searchRequestType={}", searchRequestType, + e); + return null; + } + if (values.isEmpty()) { + return null; + } + final SortedSet includeValues = new TreeSet<>(); + values.forEach(value -> includeValues.add(new BytesRef(value))); + return new IncludeExclude(includeValues, null); + } + /** * Builds the highlighting configuration. * diff --git a/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java b/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java index ad2c9d399f..e53ab1b22e 100644 --- a/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/FuzzyQueryCommand.java @@ -85,6 +85,11 @@ protected QueryBuilder convertFuzzyQuery(final QueryContext context, final Fuzzy final Term term = fuzzyQuery.getTerm(); final String field = getSearchField(context.getDefaultField(), term.field()); + if (isRestrictedTagField(context, field)) { + // a tag value encodes the name and the owner of a tag: a fuzzy match on it would probe for tags the caller cannot see + return buildHiddenTagQuery(context, field, term.text()); + } + if (Constants.DEFAULT_FIELD.equals(field)) { final String text = term.text(); context.addFieldLog(field, text); diff --git a/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java b/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java index 3388c084d8..7afd4639ec 100644 --- a/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/PhraseQueryCommand.java @@ -30,6 +30,7 @@ import org.lastaflute.core.message.UserMessages; import org.codelibs.fesen.opensearch.index.query.MatchPhraseQueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilder; +import org.codelibs.fesen.opensearch.index.query.QueryBuilders; /** * Query command for phrase queries. @@ -98,6 +99,14 @@ protected QueryBuilder convertPhraseQuery(final FessConfig fessConfig, final Que final String text = String.join(" ", texts); final int slop = phraseQuery.getSlop(); + if (isRestrictedTagField(context, field)) { + if (texts.length != 1 || !isVisibleTag(context, texts[0])) { + return buildHiddenTagQuery(context, field, text); + } + context.addFieldLog(field, text); + return QueryBuilders.termQuery(field, texts[0]).boost(boost); + } + if (Constants.DEFAULT_FIELD.equals(field)) { context.addFieldLog(field, text); stream(texts).of(stream -> stream.forEach(t -> context.addHighlightedQuery(t))); diff --git a/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java b/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java index 5702951aca..2583bf5683 100644 --- a/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/PrefixQueryCommand.java @@ -76,6 +76,11 @@ protected QueryBuilder convertPrefixQuery(final QueryContext context, final Pref final String field = getSearchField(context.getDefaultField(), prefixQuery.getField()); final String text = prefixQuery.getPrefix().text(); + if (isRestrictedTagField(context, field)) { + // a tag value encodes the name and the owner of a tag: a prefix on it would probe for tags the caller cannot see + return buildHiddenTagQuery(context, field, text + "*"); + } + if (Constants.DEFAULT_FIELD.equals(field)) { context.addFieldLog(field, text + "*"); context.addHighlightedQuery(text); diff --git a/src/main/java/org/codelibs/fess/query/QueryCommand.java b/src/main/java/org/codelibs/fess/query/QueryCommand.java index beca57fd7e..e2e47a557d 100644 --- a/src/main/java/org/codelibs/fess/query/QueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/QueryCommand.java @@ -18,17 +18,23 @@ import static org.codelibs.core.stream.StreamUtil.stream; import java.lang.Character.UnicodeBlock; +import java.util.List; +import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.apache.lucene.search.Query; import org.codelibs.core.lang.StringUtil; import org.codelibs.fess.Constants; import org.codelibs.fess.entity.QueryContext; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; import org.codelibs.fess.mylasta.direction.FessConfig; import org.codelibs.fess.util.ComponentUtil; import org.dbflute.optional.OptionalThing; import org.lastaflute.web.util.LaRequestUtil; import org.codelibs.fesen.opensearch.index.query.BoolQueryBuilder; import org.codelibs.fesen.opensearch.index.query.DisMaxQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.MatchNoneQueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilder; import org.codelibs.fesen.opensearch.index.query.QueryBuilders; import org.codelibs.fesen.opensearch.search.sort.SortBuilder; @@ -40,6 +46,7 @@ * Provides common functionality for processing and executing search queries. */ public abstract class QueryCommand { + private static final Logger logger = LogManager.getLogger(QueryCommand.class); /** * Default constructor for QueryCommand. @@ -112,6 +119,72 @@ protected boolean isSearchField(final String field) { return config.searchFieldSet != null && config.searchFieldSet.contains(field); } + /** + * Checks if a condition on the field must be limited to the tags that the caller can see. A value + * of the {@code tag} field decodes to the name and the owner of a tag, and the field of a document + * holds the values of every tag put on it, so a condition on it is restricted for every search + * except the admin search. + * @param context The query context. + * @param field The field name. + * @return True if the field is the tag field and the condition must be restricted. + */ + protected boolean isRestrictedTagField(final QueryContext context, final String field) { + return field != null && field.equals(ComponentUtil.getFessConfig().getIndexFieldTag()) + && !SearchRequestType.ADMIN_SEARCH.equals(context.getSearchRequestType()); + } + + /** + * Checks if the caller can see a tag. Nothing is visible while user tags are disabled or to a + * caller who is not logged in. Each value is resolved once per query context. + * @param context The query context. + * @param value The tag value. + * @return True if the tag is visible to the caller. + */ + protected boolean isVisibleTag(final QueryContext context, final String value) { + if (!ComponentUtil.getFessConfig().isUserTagEnabled()) { + return false; + } + final Map visibilityMap = context.getTagVisibilityMap(); + final Boolean cached = visibilityMap.get(value); + if (cached != null) { + return cached; + } + final boolean visible = resolveTagVisibility(context, value); + visibilityMap.put(value, visible); + return visible; + } + + /** + * Resolves whether the caller can see a tag. + * @param context The query context. + * @param value The tag value. + * @return True if the tag is visible, false if it is not or cannot be resolved. + */ + protected boolean resolveTagVisibility(final QueryContext context, final String value) { + final SearchRequestType searchRequestType = + context.getSearchRequestType() != null ? context.getSearchRequestType() : SearchRequestType.JSON; + try { + return ComponentUtil.getTagTypeHelper().getVisibleTagTypes(List.of(value), searchRequestType).containsKey(value); + } catch (final RuntimeException e) { + logger.warn("Failed to resolve the visibility of a tag; the tag condition matches nothing. searchRequestType={}", + searchRequestType, e); + return false; + } + } + + /** + * Builds the query for a condition on the tag field that the caller may not use: it matches no document, so the + * condition does not reveal which documents carry a tag that the caller cannot see. + * @param context The query context. + * @param field The field name. + * @param text The query text. + * @return A query that matches no document. + */ + protected QueryBuilder buildHiddenTagQuery(final QueryContext context, final String field, final String text) { + context.addFieldLog(field, text); + return new MatchNoneQueryBuilder(); + } + /** * Gets the query languages from the current request. * @return An optional containing the query languages array, or empty if not available. diff --git a/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java b/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java index 49a834a949..2dff089863 100644 --- a/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java +++ b/src/main/java/org/codelibs/fess/query/QueryFieldConfig.java @@ -224,6 +224,7 @@ public void init() { fessConfig.getIndexFieldAnchor(), // fessConfig.getIndexFieldClickCount(), // fessConfig.getIndexFieldFavoriteCount(), // + fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldLang()); // Initialize Set for O(1) lookup performance searchFieldSet = new HashSet<>(); @@ -243,6 +244,7 @@ public void init() { fessConfig.getIndexFieldOwner(), // fessConfig.getIndexFieldLastModifier(), // fessConfig.getIndexFieldLabel(), // + fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldSegment()); // Initialize Set for O(1) lookup performance facetFieldSet = new HashSet<>(); @@ -321,6 +323,7 @@ public void init() { fessConfig.getIndexFieldSegment(), // fessConfig.getIndexFieldSeqNo(), // fessConfig.getIndexFieldSite(), // + fessConfig.getIndexFieldTag(), // fessConfig.getIndexFieldTimestamp(), // fessConfig.getIndexFieldUrl(), // fessConfig.getIndexFieldVersion())); diff --git a/src/main/java/org/codelibs/fess/query/TermQueryCommand.java b/src/main/java/org/codelibs/fess/query/TermQueryCommand.java index 74f7fe2740..a44a0bc00e 100644 --- a/src/main/java/org/codelibs/fess/query/TermQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/TermQueryCommand.java @@ -98,6 +98,9 @@ protected QueryBuilder convertTermQuery(final QueryContext context, final TermQu */ protected QueryBuilder convertTermQuery(final FessConfig fessConfig, final QueryContext context, final TermQuery termQuery, final float boost, final String field, final String text) { + if (isRestrictedTagField(context, field) && !isVisibleTag(context, text)) { + return buildHiddenTagQuery(context, field, text); + } if (fessConfig.getQueryReplaceTermWithPrefixQueryAsBoolean() && text.length() > 1 && text.endsWith("*")) { return convertPrefixQuery(fessConfig, context, termQuery, boost, field, text); } diff --git a/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java b/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java index 2e9d102855..1de5cc25a4 100644 --- a/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/TermRangeQueryCommand.java @@ -74,6 +74,11 @@ protected QueryBuilder convertTermRangeQuery(final QueryContext context, final T final FessConfig fessConfig = ComponentUtil.getFessConfig(); final String field = getSearchField(context.getDefaultField(), termRangeQuery.getField()); + if (isRestrictedTagField(context, field)) { + // a tag value encodes the name and the owner of a tag: a range on it would probe for tags the caller cannot see + return buildHiddenTagQuery(context, field, termRangeQuery.toString(field)); + } + if (!isSearchField(field)) { final StringBuilder queryBuf = new StringBuilder(); queryBuf.append(termRangeQuery.includesLower() ? '[' : '{'); diff --git a/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java b/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java index aa0188d229..9d00b7bf88 100644 --- a/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java +++ b/src/main/java/org/codelibs/fess/query/WildcardQueryCommand.java @@ -80,6 +80,10 @@ public QueryBuilder execute(final QueryContext context, final Query query, final protected QueryBuilder convertWildcardQuery(final QueryContext context, final WildcardQuery wildcardQuery, final float boost) { final FessConfig fessConfig = ComponentUtil.getFessConfig(); final String field = getSearchField(context.getDefaultField(), wildcardQuery.getField()); + if (isRestrictedTagField(context, field)) { + // a tag value encodes the name and the owner of a tag: a pattern on it would probe for tags the caller cannot see + return buildHiddenTagQuery(context, field, wildcardQuery.getTerm().text()); + } if (Constants.DEFAULT_FIELD.equals(field)) { final String text = wildcardQuery.getTerm().text(); context.addFieldLog(field, text); diff --git a/src/main/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcher.java b/src/main/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcher.java index 6f22859b5a..dc15428703 100644 --- a/src/main/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcher.java +++ b/src/main/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcher.java @@ -940,5 +940,10 @@ public Float getMinScore() { public List getFilterQueries() { return parent.getFilterQueries(); } + + @Override + public String[] getResponseFields() { + return parent.getResponseFields(); + } } } diff --git a/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientFacetFieldTest.java b/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientFacetFieldTest.java index b17e83e901..5ea6dad81c 100644 --- a/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientFacetFieldTest.java +++ b/src/test/java/org/codelibs/fess/opensearch/client/SearchEngineClientFacetFieldTest.java @@ -15,7 +15,16 @@ */ package org.codelibs.fess.opensearch.client; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + import org.codelibs.fess.entity.FacetInfo; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.exentity.TagType; import org.codelibs.fess.exception.InvalidQueryException; import org.codelibs.fess.opensearch.client.SearchEngineClient.SearchConditionBuilder; import org.codelibs.fess.query.QueryFieldConfig; @@ -25,6 +34,8 @@ import org.junit.jupiter.api.TestInfo; import org.codelibs.fesen.opensearch.action.search.SearchAction; import org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.AggregationBuilder; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.TermsAggregationBuilder; /** * Verifies that a facet field outside the allowlist is reported as an invalid @@ -43,14 +54,52 @@ public class SearchEngineClientFacetFieldTest extends UnitFessTestCase { private QueryFieldConfig queryFieldConfig; + private boolean userTagEnabled; + + private Set visibleTagValues; + + private SearchRequestType resolvedType; + + private int resolveCount; + @Override protected void setUp(final TestInfo testInfo) throws Exception { super.setUp(testInfo); queryFieldConfig = new QueryFieldConfig(); // setFacetFields() repopulates the lookup set, so init() (and its long // list of index.field.* getters) is not needed here. - queryFieldConfig.setFacetFields(new String[] { "label", "filetype" }); + queryFieldConfig.setFacetFields(new String[] { "label", "filetype", "tag" }); ComponentUtil.register(queryFieldConfig, "queryFieldConfig"); + userTagEnabled = true; + visibleTagValues = new LinkedHashSet<>(); + resolvedType = null; + resolveCount = 0; + ComponentUtil.setFessConfig(new FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public boolean isUserTagEnabled() { + return userTagEnabled; + } + + @Override + public String getIndexFieldTag() { + return "tag"; + } + }); + ComponentUtil.register(new TagTypeHelper() { + @Override + public Set getVisibleTagValues(final SearchRequestType type) { + resolveCount++; + resolvedType = type; + return visibleTagValues; + } + + @Override + public Map getVisibleTagTypes(final Collection values, final SearchRequestType type) { + throw new AssertionError("the facet must not look up tags one by one"); + } + }, "tagTypeHelper"); } @Test @@ -93,6 +142,73 @@ public void test_buildFacet_supportedFields() { assertEquals(2, searchRequestBuilder.request().source().aggregations().count()); } + private SearchRequestBuilder buildTagFacet(final SearchRequestType type) { + final FacetInfo facetInfo = new FacetInfo(); + facetInfo.field = new String[] { "label", "tag" }; + final SearchRequestBuilder searchRequestBuilder = newSearchRequestBuilder(); + SearchConditionBuilder.builder(searchRequestBuilder) + .facetInfo(facetInfo) + .searchRequestType(type) + .buildFacet(null, queryFieldConfig, ComponentUtil.getFessConfig()); + return searchRequestBuilder; + } + + private TermsAggregationBuilder findTagAggregation(final SearchRequestBuilder searchRequestBuilder) { + for (final AggregationBuilder aggregation : searchRequestBuilder.request().source().aggregations().getAggregatorFactories()) { + if (aggregation instanceof final TermsAggregationBuilder terms && "tag".equals(terms.field())) { + return terms; + } + } + return null; + } + + @Test + public void test_buildFacet_tag_includesOnlyVisibleTags() { + final String own = TagType.toTagValue("foo", "alice"); + final String shared = TagType.toTagValue("foo", "bob"); + visibleTagValues.add(own); + visibleTagValues.add(shared); + + final SearchRequestBuilder searchRequestBuilder = buildTagFacet(SearchRequestType.JSON); + + final TermsAggregationBuilder terms = findTagAggregation(searchRequestBuilder); + assertNotNull(terms); + assertNotNull(terms.includeExclude()); + final String json = searchRequestBuilder.request().source().toString().replaceAll("\\s", ""); + // the same name of two owners is two separate values, and only those two may become buckets + assertTrue(json.contains("\"include\":[\"" + own + "\",\"" + shared + "\"]") + || json.contains("\"include\":[\"" + shared + "\",\"" + own + "\"]"), json); + assertEquals(SearchRequestType.JSON, resolvedType); + assertEquals(1, resolveCount); + } + + @Test + public void test_buildFacet_tag_noVisibleTags_notAggregated() { + // an anonymous caller sees no tag: the tag facet is left out, the others are kept, no error + final SearchRequestBuilder searchRequestBuilder = buildTagFacet(SearchRequestType.SEARCH); + assertNull(findTagAggregation(searchRequestBuilder)); + assertEquals(1, searchRequestBuilder.request().source().aggregations().count()); + assertEquals(SearchRequestType.SEARCH, resolvedType); + } + + @Test + public void test_buildFacet_tag_disabled_notAggregated() { + userTagEnabled = false; + visibleTagValues.add(TagType.toTagValue("foo", "alice")); + final SearchRequestBuilder searchRequestBuilder = buildTagFacet(SearchRequestType.JSON); + assertNull(findTagAggregation(searchRequestBuilder)); + assertEquals(0, resolveCount); + } + + @Test + public void test_buildFacet_tag_adminSearch_unrestricted() { + final SearchRequestBuilder searchRequestBuilder = buildTagFacet(SearchRequestType.ADMIN_SEARCH); + final TermsAggregationBuilder terms = findTagAggregation(searchRequestBuilder); + assertNotNull(terms); + assertNull(terms.includeExclude()); + assertEquals(0, resolveCount); + } + private void buildFacet(final FacetInfo facetInfo) { SearchConditionBuilder.builder(newSearchRequestBuilder()) .facetInfo(facetInfo) diff --git a/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java b/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java index 6fd560d627..c977916f56 100644 --- a/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/MatchAllQueryCommandTest.java @@ -119,6 +119,11 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryCommandTest.java b/src/test/java/org/codelibs/fess/query/QueryCommandTest.java index d01160e1ce..78d6c7a733 100644 --- a/src/test/java/org/codelibs/fess/query/QueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryCommandTest.java @@ -124,6 +124,11 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java b/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java index 17bc987ac2..82dc1af8a8 100644 --- a/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryFieldConfigSetBasedLookupTest.java @@ -473,6 +473,11 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java b/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java index c824444fa4..534e260ffa 100644 --- a/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java +++ b/src/test/java/org/codelibs/fess/query/QueryFieldConfigTest.java @@ -172,6 +172,11 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } + @Override + public String getIndexFieldTag() { + return "tag"; + } + @Override public String getIndexFieldCreated() { return "created"; @@ -1061,6 +1066,25 @@ public void test_isApiResponseField_defaultAllowlist_allowsSafeFields() { "default API response allowlist size changed; actual set: " + config.apiResponseFieldSet); } + @Test + public void test_defaultConfig_tagFields() { + final QueryFieldConfig config = ComponentUtil.getQueryFieldConfig(); + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final String tag = fessConfig.getIndexFieldTag(); + + // tag:, fields.tag= and facet.field=tag work + assertTrue(config.searchFieldSet.contains(tag)); + assertTrue(config.isFacetField(tag)); + assertTrue(config.notAnalyzedFieldSet.contains(tag)); + // a raw tag value decodes to the name and the owner of a tag, including tags the caller + // cannot see: no response returns the field; the v2 search adds it to its own request + // and returns only the visible tags + assertFalse(Arrays.asList(config.getResponseFields()).contains(tag)); + assertFalse(config.isApiResponseField(tag)); + assertFalse(Arrays.asList(config.getScrollResponseFields()).contains(tag)); + assertFalse(Arrays.asList(config.getCacheResponseFields()).contains(tag)); + } + @Test public void test_defaultConfig_ownerAndLastModifierAreSearchableFields() { final QueryFieldConfig config = ComponentUtil.getQueryFieldConfig(); diff --git a/src/test/java/org/codelibs/fess/query/QueryTestBase.java b/src/test/java/org/codelibs/fess/query/QueryTestBase.java index d6b5c71b33..3b1c6f7ce9 100644 --- a/src/test/java/org/codelibs/fess/query/QueryTestBase.java +++ b/src/test/java/org/codelibs/fess/query/QueryTestBase.java @@ -30,6 +30,9 @@ public abstract class QueryTestBase extends UnitFessTestCase { protected QueryProcessor queryProcessor; + /** The value of {@code user.tag.enabled} that the base FessConfig returns. */ + protected boolean userTagEnabled = true; + @Override protected void setUp(TestInfo testInfo) throws Exception { super.setUp(testInfo); @@ -163,6 +166,16 @@ public String getIndexFieldLastModifier() { return "last_modifier"; } + @Override + public String getIndexFieldTag() { + return "tag"; + } + + @Override + public boolean isUserTagEnabled() { + return userTagEnabled; + } + @Override public String getIndexFieldCreated() { return "created"; diff --git a/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java b/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java new file mode 100644 index 0000000000..d04d8042db --- /dev/null +++ b/src/test/java/org/codelibs/fess/query/TagQueryRestrictionTest.java @@ -0,0 +1,352 @@ +/* + * Copyright 2012-2025 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.query; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import org.codelibs.fess.entity.QueryContext; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.helper.VirtualHostHelper; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.query.StructuredQuerySplitter.Split; +import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fesen.opensearch.index.query.BoolQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.MatchNoneQueryBuilder; +import org.codelibs.fesen.opensearch.index.query.QueryBuilder; +import org.codelibs.fesen.opensearch.index.query.TermQueryBuilder; +import org.junit.jupiter.api.Test; + +/** + * A condition on the {@code tag} field matches only the tags that the caller can see. + */ +public class TagQueryRestrictionTest extends QueryTestBase { + + private static final String VISIBLE = TagType.toTagValue("report", "alice"); + + private static final String HIDDEN = TagType.toTagValue("secret", "bob"); + + private final List> resolvedValues = new ArrayList<>(); + + private SearchRequestType resolvedType; + + @Override + protected void setUpChild() throws Exception { + new TermQueryCommand().register(); + new PhraseQueryCommand().register(); + new WildcardQueryCommand().register(); + new PrefixQueryCommand().register(); + new FuzzyQueryCommand().register(); + new TermRangeQueryCommand().register(); + new BooleanQueryCommand().register(); + new BoostQueryCommand().register(); + resolvedValues.clear(); + resolvedType = null; + ComponentUtil.register(new TagTypeHelper() { + @Override + public Map getVisibleTagTypes(final Collection values, final SearchRequestType type) { + resolvedValues.add(new ArrayList<>(values)); + resolvedType = type; + final Map result = new HashMap<>(); + if (values.contains(VISIBLE)) { + final TagType tagType = new TagType(); + tagType.setName("report"); + tagType.setOwner("alice"); + result.put(VISIBLE, tagType); + } + return result; + } + }, "tagTypeHelper"); + } + + private QueryBuilder build(final String query, final SearchRequestType searchRequestType) { + final QueryContext context = new QueryContext(query, false); + context.setSearchRequestType(searchRequestType); + return queryProcessor.execute(context, ComponentUtil.getQueryParser().parse(query), 1.0f); + } + + private QueryBuilder build(final String query) { + return build(query, SearchRequestType.JSON); + } + + private static String quoted(final String value) { + return "tag:\"" + value + "\""; + } + + private void assertTermQuery(final QueryBuilder builder, final String value) { + assertTrue(builder instanceof TermQueryBuilder, "expected a term query: " + builder); + final TermQueryBuilder termQuery = (TermQueryBuilder) builder; + assertEquals("tag", termQuery.fieldName()); + assertEquals(value, termQuery.value()); + } + + private void assertMatchNone(final QueryBuilder builder) { + assertTrue(builder instanceof MatchNoneQueryBuilder, "expected a query matching nothing: " + builder); + } + + @Test + public void test_fieldsTag_visible() { + // fields.tag= is turned into tag:"" + assertTermQuery(build(quoted(VISIBLE)), VISIBLE); + } + + @Test + public void test_fieldsTag_hidden() { + assertMatchNone(build(quoted(HIDDEN))); + } + + @Test + public void test_queryTag_visible() { + final String value = "abc"; + ComponentUtil.register(new TagTypeHelper() { + @Override + public Map getVisibleTagTypes(final Collection values, final SearchRequestType type) { + return values.contains(value) ? Map.of(value, new TagType()) : Map.of(); + } + }, "tagTypeHelper"); + assertTermQuery(build("tag:" + value), value); + } + + @Test + public void test_queryTag_hidden() { + assertMatchNone(build("tag:hidden")); + } + + @Test + public void test_queryTag_hiddenWithKeyword() { + final QueryBuilder builder = build("report " + quoted(HIDDEN)); + assertTrue(builder instanceof BoolQueryBuilder, builder.toString()); + assertTrue(builder.toString().contains("match_none"), builder.toString()); + assertFalse(builder.toString().contains(HIDDEN), builder.toString()); + } + + @Test + public void test_queryTag_patterns() { + assertMatchNone(build("tag:c2Vj*")); + assertMatchNone(build("tag:c2Vj?mV0")); + assertMatchNone(build("tag:c2VjcmV0~")); + assertMatchNone(build("tag:[a TO z]")); + assertMatchNone(build("tag:\"" + VISIBLE + " " + HIDDEN + "\"")); + // nothing was resolved: a pattern never becomes a condition on a tag + assertTrue(resolvedValues.isEmpty(), resolvedValues.toString()); + } + + @Test + public void test_queryTag_negatedHidden() { + final QueryBuilder builder = build("-" + quoted(HIDDEN)); + assertTrue(builder instanceof BoolQueryBuilder, builder.toString()); + final BoolQueryBuilder boolQuery = (BoolQueryBuilder) builder; + assertEquals(1, boolQuery.mustNot().size()); + assertMatchNone(boolQuery.mustNot().get(0)); + } + + @Test + public void test_queryTag_resolvedOncePerValue() { + build(quoted(VISIBLE) + " OR " + quoted(HIDDEN) + " OR " + quoted(VISIBLE) + " OR " + quoted(HIDDEN)); + assertEquals(2, resolvedValues.size(), resolvedValues.toString()); + assertEquals(List.of(VISIBLE), resolvedValues.get(0)); + assertEquals(List.of(HIDDEN), resolvedValues.get(1)); + assertEquals(SearchRequestType.JSON, resolvedType); + } + + @Test + public void test_queryTag_unknownRequestType() { + assertMatchNone(build(quoted(HIDDEN), null)); + assertEquals(SearchRequestType.JSON, resolvedType); + } + + @Test + public void test_queryTag_adminSearch() { + assertTermQuery(build(quoted(HIDDEN), SearchRequestType.ADMIN_SEARCH), HIDDEN); + assertTrue(resolvedValues.isEmpty()); + } + + @Test + public void test_queryTag_adminSearch_patternsUnrestricted() { + assertFalse(build("tag:c2Vj*", SearchRequestType.ADMIN_SEARCH) instanceof MatchNoneQueryBuilder); + } + + @Test + public void test_queryTag_disabled_matchesNothing() { + userTagEnabled = false; + assertMatchNone(build(quoted(VISIBLE))); + assertTrue(resolvedValues.isEmpty(), resolvedValues.toString()); + // the admin search is not restricted, enabled or not + assertTermQuery(build(quoted(HIDDEN), SearchRequestType.ADMIN_SEARCH), HIDDEN); + } + + @Test + public void test_otherField_notResolved() { + final QueryBuilder builder = build("title:" + "hidden"); + assertFalse(builder instanceof MatchNoneQueryBuilder, builder.toString()); + assertTrue(resolvedValues.isEmpty()); + } + + @Test + public void test_queryTag_resolutionFails() { + ComponentUtil.register(new TagTypeHelper() { + @Override + public Map getVisibleTagTypes(final Collection values, final SearchRequestType type) { + throw new IllegalStateException("not available"); + } + }, "tagTypeHelper"); + assertMatchNone(build(quoted(VISIBLE))); + } + + // ------------------------------------------------------------------------------------- + // visibility from the real TagTypeHelper + // -------------------------------------- + + /** + * Registers a TagTypeHelper that decides visibility itself from the given tags, as the caller + * {@code userId} (null when not logged in) holding the given roles. + */ + private void registerRealVisibility(final String userId, final Set roles, final TagType... tagTypes) { + ComponentUtil.register(new VirtualHostHelper() { + @Override + public String getVirtualHostKey() { + return ""; + } + }, "virtualHostHelper"); + ComponentUtil.register(new TagTypeHelper() { + // the mock request lives across the callers of a test: keep each caller's cache apart + private final Map> cache = new HashMap<>(); + + @Override + protected Map> getVisibleTagTypeCache(final SearchRequestType type) { + return cache; + } + + @Override + protected String getLoggedInUserId() { + return userId; + } + + @Override + protected Set getViewerRoles(final SearchRequestType type) { + return roles; + } + + @Override + protected Map fetchTagTypes(final Collection values) { + final Map result = new LinkedHashMap<>(); + for (final TagType tagType : tagTypes) { + if (values.contains(tagType.getTagValue())) { + result.put(tagType.getTagValue(), tagType); + } + } + return result; + } + }, "tagTypeHelper"); + } + + private static TagType tagType(final String name, final String owner, final String... permissions) { + final TagType tagType = new TagType(); + tagType.setName(name); + tagType.setOwner(owner); + tagType.setPermissions(permissions); + tagType.setVirtualHost(""); + return tagType; + } + + @Test + public void test_sameNameDifferentOwner_isolated() { + // alice and bob each have a tag named foo; only bob's is shared + final TagType aliceFoo = tagType("foo", "alice", "1alice"); + final TagType bobFoo = tagType("foo", "bob", "1bob", "Rguest"); + final TagType carolFoo = tagType("foo", "carol", "1carol"); + assertFalse(aliceFoo.getTagValue().equals(bobFoo.getTagValue())); + + // alice sees her own foo and bob's shared foo as two separate conditions, never carol's + registerRealVisibility("alice", Set.of("1alice", "Rguest"), aliceFoo, bobFoo, carolFoo); + assertTermQuery(build(quoted(aliceFoo.getTagValue())), aliceFoo.getTagValue()); + assertTermQuery(build(quoted(bobFoo.getTagValue())), bobFoo.getTagValue()); + assertMatchNone(build(quoted(carolFoo.getTagValue()))); + + // bob cannot reach alice's private foo through his own tag of the same name + registerRealVisibility("bob", Set.of("1bob", "Rguest"), aliceFoo, bobFoo, carolFoo); + assertTermQuery(build(quoted(bobFoo.getTagValue())), bobFoo.getTagValue()); + assertMatchNone(build(quoted(aliceFoo.getTagValue()))); + } + + @Test + public void test_anonymous_tagConditionMatchesNothing() { + final TagType shared = tagType("foo", "bob", "1bob", "Rguest"); + registerRealVisibility(null, Set.of("Rguest"), shared); + // fields.tag with a valid value of a shared tag: no error, no match + assertMatchNone(build(quoted(shared.getTagValue()))); + assertMatchNone(build(quoted(shared.getTagValue()), SearchRequestType.SEARCH)); + final QueryBuilder withKeyword = build("report " + quoted(shared.getTagValue())); + assertTrue(withKeyword.toString().contains("match_none"), withKeyword.toString()); + } + + // ------------------------------------------------------------------------------------- + // vector leg + // ---------- + + /** + * The vector leg builds the conditions of the query with the same commands as the keyword leg, + * so a condition on the tag field has to see the request type of the search that is running. + */ + private QueryBuilder splitFilter(final String query, final SearchRequestType searchRequestType) { + final Split split = new StructuredQuerySplitter().split(query, searchRequestType); + assertNotNull(split); + assertEquals("report", split.text); + assertNotNull(split.conditionFilter); + return split.conditionFilter; + } + + @Test + public void test_vectorLeg_adminSearch_keepsTheTagOfAnyValue() { + // Like the keyword leg of an admin search, the vector leg is not limited to the visible tags. + final String filter = splitFilter("report " + quoted(HIDDEN), SearchRequestType.ADMIN_SEARCH).toString(); + assertTrue(filter.contains(HIDDEN), filter); + assertFalse(filter.contains("match_none"), filter); + assertTrue(resolvedValues.isEmpty()); + } + + @Test + public void test_vectorLeg_jsonSearch_stillLimitedToVisibleTags() { + final String filter = splitFilter("report " + quoted(HIDDEN), SearchRequestType.JSON).toString(); + assertTrue(filter.contains("match_none"), filter); + assertFalse(filter.contains(HIDDEN), filter); + assertEquals(SearchRequestType.JSON, resolvedType); + assertTrue(splitFilter("report " + quoted(VISIBLE), SearchRequestType.JSON).toString().contains(VISIBLE)); + } + + @Test + public void test_vectorLeg_htmlSearch_limitedToTheTagsOfThatRequestType() { + final String filter = splitFilter("report " + quoted(HIDDEN), SearchRequestType.SEARCH).toString(); + assertTrue(filter.contains("match_none"), filter); + assertFalse(filter.contains(HIDDEN), filter); + // the visible tags are resolved for the HTML search, not for the JSON API + assertEquals(SearchRequestType.SEARCH, resolvedType); + } + + @Test + public void test_vectorLeg_unknownRequestType_stillLimitedToVisibleTags() { + final String filter = splitFilter("report " + quoted(HIDDEN), null).toString(); + assertTrue(filter.contains("match_none"), filter); + assertEquals(SearchRequestType.JSON, resolvedType); + } +} diff --git a/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java b/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java index 4d71838830..4a9124aef2 100644 --- a/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java +++ b/src/test/java/org/codelibs/fess/query/TermQueryCommandTest.java @@ -198,6 +198,11 @@ public String getIndexFieldLastModifier() { return baseConfig.getIndexFieldLastModifier(); } + @Override + public String getIndexFieldTag() { + return baseConfig.getIndexFieldTag(); + } + @Override public String getIndexFieldCreated() { return baseConfig.getIndexFieldCreated(); diff --git a/src/test/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcherTest.java b/src/test/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcherTest.java index aada2cb4d9..9f2c7429dd 100644 --- a/src/test/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcherTest.java +++ b/src/test/java/org/codelibs/fess/rank/fusion/SemanticChunkSearcherTest.java @@ -643,6 +643,20 @@ public java.util.List getFilterQueries() { assertEquals(filters, new RankFusionProcessor.SearchRequestParamsWrapper(params, 0, 5).getFilterQueries()); } + @Test + public void test_semanticSearchRequestParams_delegatesResponseFields() { + // a request that adds a field to its response (the v2 search adds tag) must get it on hits of the vector leg too + final String[] fields = { "title", "url", "tag" }; + final StubSearchRequestParams params = new StubSearchRequestParams(0, 10) { + @Override + public String[] getResponseFields() { + return fields; + } + }; + org.junit.jupiter.api.Assertions.assertArrayEquals(fields, + new SemanticChunkSearcher.SemanticSearchRequestParams(params).getResponseFields()); + } + // ------------------------------------------------------------------------------------- // min_score // --------- From 493dd8bd39336b6504e50fce6d018f895125fbf8 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 20:24:48 +0900 Subject: [PATCH 09/14] feat(api): add the v2 tag endpoints for per-user tags Logged-in users manage their own tags and put them on documents through the v2 API; the shared tags of other users can be seen and filtered on but not changed. - GET/POST /api/v2/tags lists the caller's tags with their path counts and creates one (409 conflict for a name the caller already uses, user.tag.max.tags per user). - PUT /api/v2/tags/{id} renames a tag (new id, the rename of the value on the documents is queued) or changes whether it is shared, which keeps any other permission; DELETE deletes it and queues the removal. - GET/POST /api/v2/documents/{docId}/tags lists the visible tags on the document's URL with the caller's addable tags, and puts a tag on it by id or by name, creating a private tag of the name when needed (user.tag.max.paths per tag). DELETE .../tags/{id} takes it off. - Changes are retried on a concurrent update; another user's tag is 403 when visible and 404 otherwise; the document is resolved with the caller's roles. - Every tag endpoint needs user.tag.enabled and a login session; an access token does not stand in for one, and writes need the CSRF token. - /api/v2/search fetches the tag field for itself, never returns it raw, and gives hits the visible tags as tags [{value, name, owner, mine, shared}] with one lookup per request; tag facet buckets carry label, owner, mine and shared. - ui/config reports features.user_tag; the OpenAPI document describes the endpoints, the hit tags and the facet fields. --- src/main/config/openapi/v2/openapi-user.yaml | 399 ++++++++++++++ .../fess/api/v2/SearchApiV2Manager.java | 30 + .../api/v2/handlers/AbstractTagHandler.java | 315 +++++++++++ .../api/v2/handlers/DocumentTagsHandler.java | 246 +++++++++ .../api/v2/handlers/LoginRequirement.java | 4 +- .../fess/api/v2/handlers/SearchHandler.java | 159 +++++- .../fess/api/v2/handlers/TagsHandler.java | 240 ++++++++ .../fess/api/v2/handlers/UiConfigHandler.java | 3 + .../fess/app/service/TagTypeService.java | 35 ++ src/main/resources/fess_api.xml | 2 + .../api/v2/SearchApiV2ManagerCsrfTest.java | 61 ++ .../SearchApiV2ManagerLoginRequiredTest.java | 3 +- .../fess/api/v2/SearchApiV2ManagerTest.java | 76 +++ .../api/v2/SearchApiV2ManagerTestSupport.java | 4 + .../CsrfRequirementCompleteCoverageTest.java | 16 +- .../v2/handlers/DocumentTagsHandlerTest.java | 308 +++++++++++ .../LoginRequirementCompleteCoverageTest.java | 6 +- .../api/v2/handlers/SearchHandlerTest.java | 270 +++++++++ .../v2/handlers/TagHandlerTestSupport.java | 519 ++++++++++++++++++ .../fess/api/v2/handlers/TagsHandlerTest.java | 373 +++++++++++++ .../api/v2/handlers/UiConfigHandlerTest.java | 30 + .../fess/app/service/TagTypeServiceTest.java | 34 ++ 22 files changed, 3120 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java create mode 100644 src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java create mode 100644 src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java create mode 100644 src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java create mode 100644 src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java create mode 100644 src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java diff --git a/src/main/config/openapi/v2/openapi-user.yaml b/src/main/config/openapi/v2/openapi-user.yaml index b48dfb1a66..22225e748e 100644 --- a/src/main/config/openapi/v2/openapi-user.yaml +++ b/src/main/config/openapi/v2/openapi-user.yaml @@ -71,6 +71,14 @@ tags: description: Monitoring operations - name: favorite description: Favorite operations + - name: tag + description: |- + Tags of logged-in users. Each user creates and manages their own tags + and puts them on documents; a shared tag of another user can be seen + and filtered on but not changed. Every tag endpoint answers + `invalid_request` (400) while `user.tag.enabled` is false, and + `auth_required` (401) to a caller without a login session: an access + token never stands in for a login here. - name: auth description: Authentication and session operations - name: ui @@ -1086,6 +1094,211 @@ paths: '415': { $ref: '#/components/responses/UnsupportedMediaType' } '500': { $ref: '#/components/responses/InternalServerError' } + /tags: + get: + tags: [tag] + summary: List the caller's tags + operationId: tagsListV2 + responses: + '200': + description: The caller's tags, by sort order and name. + content: + application/json: + schema: + $ref: '#/components/schemas/TagsListResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: [tag] + summary: Create a tag + description: |- + Creates a tag of the caller. The name is NFKC-normalized, whitespace + runs collapse to one space and the ends are trimmed; it then has 1 to + `user.tag.name.max.length` characters and no control or format + characters. A caller has at most `user.tag.max.tags` tags + (`invalid_request`). A tag of the same name of the caller already + existing is `conflict` (409); the same name of another user is a + different tag. + operationId: tagsCreateV2 + parameters: + - { $ref: '#/components/parameters/CsrfHeader' } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagCreateRequest' + responses: + '200': + description: Tag created. + content: + application/json: + schema: + $ref: '#/components/schemas/TagResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + '415': { $ref: '#/components/responses/UnsupportedMediaType' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /tags/{tagId}: + parameters: + - { $ref: '#/components/parameters/TagId' } + put: + tags: [tag] + summary: Rename a tag or change whether it is shared + description: |- + Only the owner can change a tag: the shared tag of another user is + `forbidden` (403) and a tag the caller cannot see is `not_found` (404). + A new `name` renames the tag, which gives it a new `id` and `value`; + the documents carrying the old value get the new one when the tag + queue is next processed (about a minute). Renaming to a name the + caller already uses is `conflict` (409) and leaves the tag unchanged. + `shared` only changes who sees the tag. A write that keeps losing a + race with another writer is also `conflict`. + operationId: tagsUpdateV2 + parameters: + - { $ref: '#/components/parameters/CsrfHeader' } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TagUpdateRequest' + responses: + '200': + description: Tag updated. + content: + application/json: + schema: + $ref: '#/components/schemas/TagUpdateResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + '415': { $ref: '#/components/responses/UnsupportedMediaType' } + '500': { $ref: '#/components/responses/InternalServerError' } + delete: + tags: [tag] + summary: Delete a tag + description: |- + Deletes a tag of the caller. The documents lose its value when the tag + queue is next processed. The shared tag of another user is `forbidden` + (403) and a tag the caller cannot see is `not_found` (404). + operationId: tagsDeleteV2 + parameters: + - { $ref: '#/components/parameters/CsrfHeader' } + responses: + '200': + description: Tag deleted. + content: + application/json: + schema: + $ref: '#/components/schemas/TagDeleteResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /documents/{docId}/tags: + parameters: + - { $ref: '#/components/parameters/DocId' } + get: + tags: [tag] + summary: List the tags of a document + description: |- + Returns the tags on the document's URL that the caller can see and + the caller's own tags that are not on it. The document is resolved + with the caller's roles; a document the caller cannot search is + `not_found` (404). + operationId: documentTagsListV2 + responses: + '200': + description: Tags of the document. + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTagsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '500': { $ref: '#/components/responses/InternalServerError' } + post: + tags: [tag] + summary: Put a tag of the caller on a document + description: |- + Adds the document's URL to a tag of the caller, given by `id`, or by + `name`, in which case a private tag of the name is created when the + caller has none (counted against `user.tag.max.tags`). A tag holds at + most `user.tag.max.paths` URLs (`invalid_request`). Another user's + tag is `forbidden` (403) or, when not visible, `not_found` (404). + Every indexed document of the URL gets the tag when the tag queue is + next processed (about a minute); the tag endpoints show it at once. + operationId: documentTagsAddV2 + parameters: + - { $ref: '#/components/parameters/CsrfHeader' } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTagAddRequest' + responses: + '200': + description: The tag is on the document. + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTagsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '409': { $ref: '#/components/responses/Conflict' } + '413': { $ref: '#/components/responses/PayloadTooLarge' } + '415': { $ref: '#/components/responses/UnsupportedMediaType' } + '500': { $ref: '#/components/responses/InternalServerError' } + + /documents/{docId}/tags/{tagId}: + parameters: + - { $ref: '#/components/parameters/DocId' } + - { $ref: '#/components/parameters/TagId' } + delete: + tags: [tag] + summary: Take a tag of the caller off a document + description: |- + Removes the document's URL from a tag of the caller. Another user's + tag is `forbidden` (403) or, when not visible, `not_found` (404). + operationId: documentTagsRemoveV2 + parameters: + - { $ref: '#/components/parameters/CsrfHeader' } + responses: + '200': + description: The tag is off the document. + content: + application/json: + schema: + $ref: '#/components/schemas/DocumentTagsResponse' + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '405': { $ref: '#/components/responses/MethodNotAllowed' } + '409': { $ref: '#/components/responses/Conflict' } + '500': { $ref: '#/components/responses/InternalServerError' } + /cache/{docId}: get: tags: [cache] @@ -1131,6 +1344,12 @@ components: description: Document identifier (matches `[A-Za-z0-9_-]+`). required: true schema: { type: string, minLength: 1, maxLength: 512, pattern: '^[A-Za-z0-9_-]+$' } + TagId: + name: tagId + in: path + description: Tag id, the SHA-256 of the tag value in lowercase hex. + required: true + schema: { type: string, pattern: '^[0-9a-f]{64}$' } CsrfHeader: name: X-Fess-CSRF-Token in: header @@ -1163,6 +1382,13 @@ components: content: application/json: schema: { $ref: '#/components/schemas/ErrorEnvelope' } + Conflict: + description: |- + The request conflicts with the current state (e.g. a tag of the name + already exists, or the tag kept changing concurrently). + content: + application/json: + schema: { $ref: '#/components/schemas/ErrorEnvelope' } MethodNotAllowed: description: |- HTTP method is not supported for this endpoint. The `Allow` header @@ -1388,12 +1614,28 @@ components: name: { type: string } result: type: array + description: |- + For the `tag` field, only the tags the caller can see, + each with `label`, `owner`, `mine` and `shared`; a + caller without a login gets no `tag` facet. items: type: object required: [value, count] properties: value: { type: string } count: { type: integer, format: int64 } + label: + type: string + description: '`tag` facet only: the tag name.' + owner: + type: string + description: '`tag` facet only: the user who owns the tag.' + mine: + type: boolean + description: '`tag` facet only: whether the caller owns the tag.' + shared: + type: boolean + description: '`tag` facet only: whether every logged-in user sees the tag.' facet_query: type: array description: Present only when facet queries were requested. @@ -1471,8 +1713,31 @@ components: description: |- Present only when result collapsing is enabled. Never present on `/documents/all`. + tags: + type: array + description: |- + `/search` only, while `user.tag.enabled` is true: the tags on the + document that the caller can see. Absent when there is none, and + always absent for a caller without a login. The raw `tag` field is + never returned. + items: + $ref: '#/components/schemas/HitTag' additionalProperties: true + HitTag: + type: object + required: [value, name, owner, mine, shared] + properties: + value: + type: string + description: |- + Tag value, `base64url(name):base64url(owner)` without padding; + filter with `fields.tag=`. + name: { type: string } + owner: { type: string, description: User who owns the tag. } + mine: { type: boolean, description: Whether the caller owns the tag. } + shared: { type: boolean, description: Whether every logged-in user sees the tag. } + # --- Suggest / labels / popular --------------------------------- SuggestWordsResponse: type: object @@ -1648,6 +1913,7 @@ components: - user_favorite - search_history - search_export + - user_tag - popular_word - suggest_search_log - suggest_documents @@ -1677,6 +1943,11 @@ components: description: |- `true` when `GET /documents/export` is available (`api.search.export`). + user_tag: + type: boolean + description: |- + `true` when user tags are enabled (`user.tag.enabled`): + the tag endpoints answer and search hits carry `tags`. popular_word: { type: boolean } suggest_search_log: { type: boolean } suggest_documents: { type: boolean } @@ -2218,6 +2489,134 @@ components: True when the favorite was previously recorded (idempotent re-POST). Absent on the first POST that newly records the favorite. + # --- Tags -------------------------------------------------------- + TagListItem: + type: object + required: [id, value, name, shared, sort_order, path_count] + properties: + id: { type: string, description: 'Tag id (SHA-256 of `value`, lowercase hex).' } + value: { type: string, description: 'Tag value, `base64url(name):base64url(owner)`.' } + name: { type: string } + shared: { type: boolean, description: Whether every logged-in user sees the tag. } + sort_order: { type: integer } + path_count: { type: integer, format: int64, description: Number of URLs the tag is on. } + + DocumentTag: + type: object + required: [id, value, name, owner, mine, shared] + properties: + id: { type: string, description: 'Tag id (SHA-256 of `value`, lowercase hex).' } + value: { type: string, description: 'Tag value, `base64url(name):base64url(owner)`.' } + name: { type: string } + owner: { type: string, description: User who owns the tag. } + mine: { type: boolean, description: Whether the caller owns the tag. } + shared: { type: boolean, description: Whether every logged-in user sees the tag. } + + TagsListResponse: + type: object + required: [response] + properties: + response: + allOf: + - $ref: '#/components/schemas/EnvelopeMeta' + - type: object + required: [tags] + properties: + tags: + type: array + items: { $ref: '#/components/schemas/TagListItem' } + + TagCreateRequest: + type: object + required: [name] + properties: + name: { type: string, description: 'Tag name, 1 to `user.tag.name.max.length` characters after normalization.' } + shared: { type: boolean, default: false, description: Whether every logged-in user sees the tag. } + + TagUpdateRequest: + type: object + description: At least one of `name` and `shared`. + properties: + name: { type: string, description: New tag name. } + shared: { type: boolean } + + TagResponse: + type: object + required: [response] + properties: + response: + allOf: + - $ref: '#/components/schemas/EnvelopeMeta' + - type: object + required: [tag] + properties: + tag: { $ref: '#/components/schemas/TagListItem' } + + TagUpdateResponse: + type: object + required: [response] + properties: + response: + allOf: + - $ref: '#/components/schemas/EnvelopeMeta' + - type: object + required: [tag, renamed] + properties: + tag: { $ref: '#/components/schemas/TagListItem' } + renamed: + type: boolean + description: Whether the tag was renamed, in which case `tag.id` and `tag.value` are new. + + TagDeleteResponse: + type: object + required: [response] + properties: + response: + allOf: + - $ref: '#/components/schemas/EnvelopeMeta' + - type: object + required: [id, deleted] + properties: + id: { type: string } + deleted: { type: boolean, enum: [true] } + + DocumentTagAddRequest: + type: object + description: Either `id` (a tag of the caller) or `name`; `id` wins when both are given. + properties: + id: { type: string, pattern: '^[0-9a-f]{64}$' } + name: { type: string } + + DocumentTagsResponse: + type: object + required: [response] + properties: + response: + allOf: + - $ref: '#/components/schemas/EnvelopeMeta' + - type: object + required: [doc_id, tags, addable] + properties: + doc_id: { type: string } + added: + type: boolean + description: '`POST` only: `false` when the tag was already on the document.' + tag: + allOf: + - $ref: '#/components/schemas/DocumentTag' + description: '`POST` only: the tag that was put on the document.' + removed: + type: boolean + description: '`DELETE` only: `false` when the tag was not on the document.' + tags: + type: array + description: Tags on the document's URL that the caller can see. + items: { $ref: '#/components/schemas/DocumentTag' } + addable: + type: array + description: The caller's tags that are not on the document's URL. + items: { $ref: '#/components/schemas/DocumentTag' } + # --- Cache ------------------------------------------------------- CacheResponse: type: object diff --git a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java index 76d1cc2989..87eb980467 100644 --- a/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java +++ b/src/main/java/org/codelibs/fess/api/v2/SearchApiV2Manager.java @@ -28,6 +28,7 @@ import org.codelibs.fess.api.v2.handlers.ChatStreamHandler; import org.codelibs.fess.api.v2.handlers.ClickHandler; import org.codelibs.fess.api.v2.handlers.CsrfRequirement; +import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler; import org.codelibs.fess.api.v2.handlers.ExportSearchHandler; import org.codelibs.fess.api.v2.handlers.FavoriteGetHandler; import org.codelibs.fess.api.v2.handlers.FavoritePostHandler; @@ -46,6 +47,7 @@ import org.codelibs.fess.api.v2.handlers.SearchHandler; import org.codelibs.fess.api.v2.handlers.SearchHistoryHandler; import org.codelibs.fess.api.v2.handlers.SuggestWordsHandler; +import org.codelibs.fess.api.v2.handlers.TagsHandler; import org.codelibs.fess.api.v2.handlers.UiConfigHandler; import org.codelibs.fess.app.service.AccessTokenService; import org.codelibs.fess.app.web.base.login.FessLoginAssist; @@ -102,6 +104,14 @@ public class SearchApiV2Manager extends BaseApiManager { @Resource protected FavoritePostHandler favoritePostHandler; + /** Handles {@code GET/POST /api/v2/tags} and {@code PUT/DELETE /api/v2/tags/{id}} (the caller's own tags). */ + @Resource + protected TagsHandler tagsHandler; + + /** Handles {@code GET/POST /api/v2/documents/{id}/tags} and {@code DELETE /api/v2/documents/{id}/tags/{tagId}}. */ + @Resource + protected DocumentTagsHandler documentTagsHandler; + /** Handles {@code GET /api/v2/favorites} (list favorited doc ids in a previously issued search result). */ @Resource protected FavoritesListHandler favoritesListHandler; @@ -302,6 +312,17 @@ public void process(final HttpServletRequest request, final HttpServletResponse } return; } + // "/documents/{docId}/tags" and "/documents/{docId}/tags/{tagId}". The doc id and the tag + // id are single non-empty segments; anything else under /documents/ falls through to the + // not_found arm below. + if (sub.startsWith("/documents/")) { + final String[] segments = sub.substring("/documents/".length()).split("/", -1); + if ((segments.length == 2 || segments.length == 3) && !segments[0].isEmpty() && "tags".equals(segments[1]) + && (segments.length == 2 || !segments[2].isEmpty())) { + documentTagsHandler.handle(request, response, segments[0], segments.length == 3 ? segments[2] : null); + return; + } + } if (sub.startsWith("/cache/")) { final String docId = sub.substring("/cache/".length()); cacheHandler.handle(request, response, docId); @@ -320,6 +341,15 @@ public void process(final HttpServletRequest request, final HttpServletResponse ComponentUtil.getV2EnvelopeWriter().writeError(response, V2ErrorCode.NOT_FOUND, "unknown action on document: " + sub); return; } + // "/tags" and "/tags/{tagId}": the tag id is one non-empty segment. + if ("/tags".equals(sub)) { + tagsHandler.handle(request, response, null); + return; + } + if (sub.startsWith("/tags/") && sub.indexOf('/', "/tags/".length()) < 0) { + tagsHandler.handle(request, response, sub.substring("/tags/".length())); + return; + } switch (sub) { case "/health" -> healthHandler.handle(request, response); case "/search" -> searchHandler.handle(request, response); diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java new file mode 100644 index 0000000000..636cac6023 --- /dev/null +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/AbstractTagHandler.java @@ -0,0 +1,315 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.fess.api.v2.V2ErrorCode; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; +import org.dbflute.optional.OptionalThing; + +import jakarta.servlet.http.HttpServletRequest; + +/** + * Shared parts of the user tag endpoints ({@link TagsHandler} and {@link DocumentTagsHandler}). + * + *

    Every tag endpoint needs {@code user.tag.enabled} and a logged-in user: the user of the login session, which an + * access token never stands in for. A caller changes only the tags it owns ({@code owner} equals + * {@link FessUserBean#getUserId()}); the tag of someone else is reported as {@link V2ErrorCode#FORBIDDEN} when the + * caller can see it and as {@link V2ErrorCode#NOT_FOUND} otherwise, so that a hidden tag is not revealed.

    + */ +public abstract class AbstractTagHandler { + + private static final Logger logger = LogManager.getLogger(AbstractTagHandler.class); + + /** The request bodies hold a short name, an id and a flag. */ + protected static final int MAX_BODY_BYTES = 1024; + + /** How many times an update that lost a race with another writer is read and applied again. */ + protected static final int MAX_UPDATE_ATTEMPTS = 3; + + /** A tag type id: the SHA-256 of the tag value in lowercase hex. */ + private static final Pattern TAG_ID_PATTERN = Pattern.compile("[0-9a-f]{64}"); + + /** + * Default constructor. + */ + protected AbstractTagHandler() { + // no-op + } + + /** A failure that is answered with an error envelope. */ + protected static class TagRequestException extends Exception { + private static final long serialVersionUID = 1L; + + /** The error code of the envelope. */ + protected final transient V2ErrorCode code; + + /** + * Creates the failure. + * + * @param code the error code of the envelope + * @param message the error message of the envelope + */ + protected TagRequestException(final V2ErrorCode code, final String message) { + super(message, null, false, false); + this.code = code; + } + } + + /** A change applied to a freshly read tag type before it is written back. */ + @FunctionalInterface + protected interface TagTypeModifier { + /** + * Changes the tag type. + * + * @param tagType the tag type as stored, with its paths + * @return true if the tag type was changed and has to be written + * @throws TagRequestException if the change is refused + */ + boolean modify(TagType tagType) throws TagRequestException; + } + + /** + * Checks that user tags are enabled and returns the logged-in user. + * + * @return the user id of the caller + * @throws TagRequestException if user tags are disabled or nobody is logged in + */ + protected String checkRequest() throws TagRequestException { + if (!ComponentUtil.getFessConfig().isUserTagEnabled()) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "tag feature is not available"); + } + final String userId = getUserBean().map(FessUserBean::getUserId).filter(StringUtil::isNotBlank).orElse(null); + if (userId == null) { + throw new TagRequestException(V2ErrorCode.AUTH_REQUIRED, "login required"); + } + return userId; + } + + /** + * Reads the JSON body of the request. + * + * @param req the request + * @return the body + * @throws TagRequestException if the body is not a small JSON object + * @throws IOException if the body cannot be read + */ + protected Map readBody(final HttpServletRequest req) throws TagRequestException, IOException { + try { + return ComponentUtil.getV2JsonBody().read(req, MAX_BODY_BYTES); + } catch (final V2JsonBody.PayloadTooLargeException e) { + throw new TagRequestException(V2ErrorCode.PAYLOAD_TOO_LARGE, e.getMessage()); + } catch (final V2JsonBody.UnsupportedMediaTypeException e) { + throw new TagRequestException(V2ErrorCode.UNSUPPORTED_MEDIA_TYPE, e.getMessage()); + } catch (final V2JsonBody.MalformedJsonException e) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, e.getMessage()); + } + } + + /** + * Returns the normalized tag name of a request. + * + * @param name the name in the request body + * @return the normalized name + * @throws TagRequestException if the name is not a valid tag name + */ + protected String toTagName(final Object name) throws TagRequestException { + final String normalized = name instanceof final String value ? getTagTypeHelper().normalizeName(value).orElse(null) : null; + if (normalized == null) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, + "invalid tag name: enter 1 to " + ComponentUtil.getFessConfig().getUserTagNameMaxLengthAsInteger() + " characters"); + } + return normalized; + } + + /** + * Returns the {@code shared} flag of a request body. + * + * @param body the request body + * @return the flag, or null when the body has none + * @throws TagRequestException if the flag is not a boolean + */ + protected Boolean toShared(final Map body) throws TagRequestException { + final Object shared = body.get("shared"); + if (shared == null || shared instanceof Boolean) { + return (Boolean) shared; + } + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "shared must be a boolean"); + } + + /** + * Builds a new tag of the caller, checking {@code user.tag.max.tags}. The tag is not stored. + * + * @param name the normalized tag name + * @param userId the caller, who owns the tag + * @param shared whether the tag is shared + * @param paths the paths of the tag + * @return the tag type with its id set + * @throws TagRequestException if the caller has {@code user.tag.max.tags} tags + */ + protected TagType newTagType(final String name, final String userId, final boolean shared, final String[] paths) + throws TagRequestException { + final int maxTags = ComponentUtil.getFessConfig().getUserTagMaxTagsAsInteger(); + if (getTagTypeService().countByOwner(userId) >= maxTags) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "too many tags: a user can have up to " + maxTags + " tags"); + } + final TagTypeHelper helper = getTagTypeHelper(); + final long now = ComponentUtil.getSystemHelper().getCurrentTimeAsLong(); + final TagType tagType = new TagType(); + tagType.setName(name); + tagType.setOwner(userId); + tagType.setId(helper.toId(tagType.getTagValue())); + tagType.setPaths(paths); + tagType.setPermissions(helper.buildPermissions(userId, shared)); + tagType.setVirtualHost(ComponentUtil.getVirtualHostHelper().getVirtualHostKey()); + tagType.setSortOrder(0); + tagType.setCreatedBy(userId); + tagType.setCreatedTime(now); + tagType.setUpdatedBy(userId); + tagType.setUpdatedTime(now); + return tagType; + } + + /** + * Reads a tag type that the caller owns, with its paths. + * + * @param id the tag type id + * @param userId the caller + * @return the tag type + * @throws TagRequestException NOT_FOUND if there is no such tag or the caller cannot see it, FORBIDDEN if the + * caller can see it but does not own it + */ + protected TagType getOwnTagType(final String id, final String userId) throws TagRequestException { + final TagType tagType = id != null && TAG_ID_PATTERN.matcher(id).matches() ? getTagTypeService().getTagType(id).orElse(null) : null; + if (tagType == null) { + throw new TagRequestException(V2ErrorCode.NOT_FOUND, "tag not found"); + } + if (!userId.equals(tagType.getOwner())) { + final String value = tagType.getTagValue(); + if (getTagTypeHelper().getVisibleTagTypes(List.of(value), SearchRequestType.JSON).containsKey(value)) { + throw new TagRequestException(V2ErrorCode.FORBIDDEN, "the tag is not yours"); + } + throw new TagRequestException(V2ErrorCode.NOT_FOUND, "tag not found"); + } + return tagType; + } + + /** + * Reads a tag type the caller owns, changes it and writes it back. A write that lost a race with another writer + * is retried on a fresh read, so that a concurrent change of the paths is not overwritten. + * + * @param id the tag type id + * @param userId the caller + * @param modifier the change + * @return the tag type as written, or as read when the modifier changed nothing + * @throws TagRequestException if the tag is not the caller's, the change is refused, or every attempt lost a race + */ + protected TagType updateTagType(final String id, final String userId, final TagTypeModifier modifier) throws TagRequestException { + for (int attempt = 1;; attempt++) { + final TagType tagType = getOwnTagType(id, userId); + if (!modifier.modify(tagType)) { + return tagType; + } + tagType.setUpdatedBy(userId); + tagType.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); + try { + getTagTypeService().update(tagType); + return tagType; + } catch (final TagTypeConflictException e) { + if (attempt >= MAX_UPDATE_ATTEMPTS) { + logger.warn("Failed to update the tag after {} attempts: id={}", attempt, id, e); + throw new TagRequestException(V2ErrorCode.CONFLICT, "the tag was changed concurrently; try again"); + } + if (logger.isDebugEnabled()) { + logger.debug("The tag was changed concurrently; retrying: id={}, attempt={}", id, attempt); + } + } + } + } + + /** + * Queues a change of the tag field of the documents. + * + * @param change the change + */ + protected void enqueue(final TagChange change) { + if (ComponentUtil.getFessConfig().isUserTagEnabled()) { + getTagTypeHelper().enqueue(change); + } + } + + /** + * Builds the JSON of a tag as a document or a search hit shows it. + * + * @param tagType the tag type + * @param userId the caller + * @return {@code {id, value, name, owner, mine, shared}} + */ + protected Map toDocumentTag(final TagType tagType, final String userId) { + final String value = tagType.getTagValue(); + final Map map = new LinkedHashMap<>(); + map.put("id", getTagTypeHelper().toId(value)); + map.put("value", value); + map.put("name", tagType.getName()); + map.put("owner", tagType.getOwner()); + map.put("mine", userId.equals(tagType.getOwner())); + map.put("shared", getTagTypeHelper().isShared(tagType)); + return map; + } + + /** + * Returns the tag type helper. + * + * @return the helper + */ + protected TagTypeHelper getTagTypeHelper() { + return ComponentUtil.getTagTypeHelper(); + } + + /** + * Returns the tag type service. Exposed as a seam for unit tests. + * + * @return the service + */ + protected TagTypeService getTagTypeService() { + return ComponentUtil.getComponent(TagTypeService.class); + } + + /** + * Returns the user of the login session. An access token is not a login session. Exposed as a seam for unit + * tests. + * + * @return the logged-in user + */ + protected OptionalThing getUserBean() { + return ComponentUtil.getFessLoginAssist().getSavedUserBean(); + } +} diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java new file mode 100644 index 0000000000..59945a423e --- /dev/null +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandler.java @@ -0,0 +1,246 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.fess.api.v2.V2ErrorCode; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fess.util.DocumentUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Handles the tags of one document: {@code GET/POST /api/v2/documents/{docId}/tags} and + * {@code DELETE /api/v2/documents/{docId}/tags/{id}}. + * + *

    A tag is put on the {@code url} of the document: the URL is added to the paths of the tag and the change of the + * tag field of every document of the URL is queued. The document is resolved with the caller's roles, so a document + * the caller cannot search is not found.

    + * + *
      + *
    • {@code GET} lists the tags on the document that the caller can see ({@code tags}) and the caller's own tags + * that are not on it ({@code addable}).
    • + *
    • {@code POST} with {@code {"id": "..."}} puts the caller's tag on the document; with {@code {"name": "..."}} it + * puts the caller's tag of the name on it, creating a private tag when there is none. A tag holds at most + * {@code user.tag.max.paths} URLs.
    • + *
    • {@code DELETE .../tags/{id}} takes the caller's tag off the document.
    • + *
    + */ +public class DocumentTagsHandler extends AbstractTagHandler { + + private static final Logger logger = LogManager.getLogger(DocumentTagsHandler.class); + + /** + * Default constructor used by the DI container. The handler holds no per-request state. + */ + public DocumentTagsHandler() { + // no-op + } + + /** + * Processes one {@code /api/v2/documents/{docId}/tags} or {@code /api/v2/documents/{docId}/tags/{id}} request. + * + * @param req the incoming HTTP request + * @param res the HTTP response to write to + * @param docId the document id from the URL path + * @param tagId the tag id of {@code .../tags/{id}}, or null for {@code .../tags} + * @throws IOException if writing the envelope fails + */ + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId, final String tagId) + throws IOException { + final String method = req.getMethod() == null ? StringUtil.EMPTY : req.getMethod().toUpperCase(Locale.ROOT); + final boolean collection = tagId == null; + if (collection ? !"GET".equals(method) && !"POST".equals(method) : !"DELETE".equals(method)) { + res.setHeader("Allow", collection ? "GET, POST" : "DELETE"); + ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.METHOD_NOT_ALLOWED, "method not allowed"); + return; + } + if (!ComponentUtil.getV2DocIdValidator().isValid(docId)) { + ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.INVALID_REQUEST, "invalid doc_id"); + return; + } + try { + final String userId = checkRequest(); + final String url = getDocumentUrl(docId); + final Map payload = new LinkedHashMap<>(); + payload.put("doc_id", docId); + switch (method) { + case "POST" -> addTag(req, userId, url, payload); + case "DELETE" -> payload.put("removed", removeTag(tagId, userId, url)); + default -> { + // GET lists the tags only + } + } + putTags(payload, userId, url); + ComponentUtil.getV2EnvelopeWriter().writeSuccess(res, payload); + } catch (final TagRequestException e) { + ComponentUtil.getV2EnvelopeWriter().writeError(res, e.code, e.getMessage()); + } catch (final Exception e) { + ComponentUtil.getV2EnvelopeWriter().writeInternalError(res, e, logger, "/api/v2/documents/" + docId + "/tags " + method); + } + } + + /** + * Puts the caller's tag of the request body on the URL and adds {@code added} and {@code tag} to the payload. + */ + private void addTag(final HttpServletRequest req, final String userId, final String url, final Map payload) + throws TagRequestException, IOException { + final Map body = readBody(req); + final String id; + if (body.get("id") instanceof final String value) { + id = value; + } else if (body.get("name") != null) { + final String name = toTagName(body.get("name")); + id = getTagTypeHelper().toId(getTagTypeHelper().toTagValue(name, userId)); + if (!getTagTypeService().getTagType(id).isPresent()) { + final TagType created = createTag(name, userId, url); + if (created != null) { + payload.put("added", true); + payload.put("tag", toDocumentTag(created, userId)); + return; + } + } + } else { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "name or id is required"); + } + final boolean[] added = { false }; + final int maxPaths = ComponentUtil.getFessConfig().getUserTagMaxPathsAsInteger(); + final TagType tagType = updateTagType(id, userId, t -> { + final String[] paths = t.getPaths() == null ? new String[0] : t.getPaths(); + if (Arrays.asList(paths).contains(url)) { + return false; + } + if (paths.length >= maxPaths) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, + "too many documents: a tag can be put on up to " + maxPaths + " documents"); + } + final String[] newPaths = Arrays.copyOf(paths, paths.length + 1); + newPaths[paths.length] = url; + t.setPaths(newPaths); + added[0] = true; + return true; + }); + if (added[0]) { + enqueue(TagChange.add(tagType.getTagValue(), url)); + } + payload.put("added", added[0]); + payload.put("tag", toDocumentTag(tagType, userId)); + } + + /** + * Creates a private tag of the caller on the URL and queues the addition. + * + * @return the tag, or null when a concurrent request created the tag of the name first + */ + private TagType createTag(final String name, final String userId, final String url) throws TagRequestException { + final TagType tagType = newTagType(name, userId, false, new String[] { url }); + try { + getTagTypeService().insert(tagType); + } catch (final TagTypeConflictException e) { + if (logger.isDebugEnabled()) { + logger.debug("The tag was created concurrently; adding the URL to it: id={}", tagType.getId()); + } + return null; + } + enqueue(TagChange.add(tagType.getTagValue(), url)); + return tagType; + } + + /** + * Takes the caller's tag off the URL. + * + * @return true if the tag was on the URL + */ + private boolean removeTag(final String tagId, final String userId, final String url) throws TagRequestException { + final boolean[] removed = { false }; + final TagType tagType = updateTagType(tagId, userId, t -> { + final String[] paths = t.getPaths() == null ? new String[0] : t.getPaths(); + final String[] newPaths = Arrays.stream(paths).filter(p -> !url.equals(p)).toArray(String[]::new); + if (newPaths.length == paths.length) { + return false; + } + t.setPaths(newPaths); + removed[0] = true; + return true; + }); + if (removed[0]) { + enqueue(TagChange.remove(tagType.getTagValue(), url)); + } + return removed[0]; + } + + /** + * Adds {@code tags}, the tags on the URL that the caller can see, and {@code addable}, the caller's tags that are + * not on the URL, to the payload. + */ + private void putTags(final Map payload, final String userId, final String url) { + final TagTypeHelper helper = getTagTypeHelper(); + final Set values = helper.findTagValuesByUrls(List.of(url)).getOrDefault(url, Collections.emptySet()); + final List> tags = new ArrayList<>(); + helper.getVisibleTagTypes(values, SearchRequestType.JSON).values().forEach(tagType -> tags.add(toDocumentTag(tagType, userId))); + final List> addable = new ArrayList<>(); + for (final TagType tagType : getTagTypeService().getTagTypeListByOwner(userId)) { + if (!values.contains(tagType.getTagValue())) { + addable.add(toDocumentTag(tagType, userId)); + } + } + payload.put("tags", tags); + payload.put("addable", addable); + } + + /** + * Returns the URL of the document, resolved with the caller's roles. + */ + private String getDocumentUrl(final String docId) throws TagRequestException { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + final Map doc = getDocument(docId, new String[] { fessConfig.getIndexFieldUrl() }); + final String url = doc == null ? null : DocumentUtil.getValue(doc, fessConfig.getIndexFieldUrl(), String.class); + if (StringUtil.isBlank(url)) { + throw new TagRequestException(V2ErrorCode.NOT_FOUND, "doc not found: " + docId); + } + return url; + } + + /** + * Fetches a document with the caller's role filter. Exposed as a seam for unit tests. + * + * @param docId the document id + * @param fields the fields to fetch + * @return the document, or null if the caller cannot see it + */ + protected Map getDocument(final String docId, final String[] fields) { + return ComponentUtil.getSearchHelper().getDocumentByDocId(docId, fields, getUserBean()).orElse(null); + } +} diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java b/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java index 86f765511b..2db65f09fd 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/LoginRequirement.java @@ -73,7 +73,7 @@ public boolean requiresLogin(final String subPath) { // login_required flag the page needs in order to show the login form at all. case "/auth/me", "/auth/login", "/auth/logout", "/ui/config" -> false; // Secure default: everything else — search, scroll, suggest, labels, popular words, - // related queries/content, chat, click, favorites, search history, cache and any endpoint added later + // related queries/content, chat, click, favorites, tags, search history, cache and any endpoint added later // — is served only to an authenticated user. default -> true; }; @@ -85,7 +85,7 @@ public boolean requiresLogin(final String subPath) { * *

    The accepted set is the endpoints on which a token already decides what the caller * sees: they resolve the caller's roles through {@code RoleQueryHelper}, which adds the - * token's permissions to an API request. Endpoints that act for a signed-in user (favorites, + * token's permissions to an API request. Endpoints that act for a signed-in user (favorites, tags, * search history, click logging, password change), generative endpoints (chat) and the cache view, which * checks the session itself, keep requiring a login.

    * diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java index 399231caf3..9e32fe8a59 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/SearchHandler.java @@ -17,9 +17,14 @@ import java.io.IOException; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -28,6 +33,7 @@ import org.codelibs.fess.api.v2.V2EnvelopeWriter; import org.codelibs.fess.api.v2.V2ErrorCode; import org.codelibs.fess.entity.SearchRenderData; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; import org.codelibs.fess.exception.InvalidQueryException; import org.codelibs.fess.exception.ResultOffsetExceededException; import org.codelibs.fess.helper.RelatedContentHelper; @@ -35,6 +41,7 @@ import org.codelibs.fess.helper.SearchHelper; import org.codelibs.fess.mylasta.action.FessUserBean; import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.exentity.TagType; import org.codelibs.fess.query.QueryFieldConfig; import org.codelibs.fess.util.ComponentUtil; import org.codelibs.fess.util.FacetResponse; @@ -103,7 +110,7 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r final SearchHelper searchHelper = ComponentUtil.getSearchHelper(); final FessConfig fessConfig = ComponentUtil.getFessConfig(); final SearchRenderData data = new SearchRenderData(); - final V2JsonRequestParams params = new V2JsonRequestParams(request, fessConfig); + final V2JsonRequestParams params = createRequestParams(request, fessConfig); params.enableRedirect(); searchHelper.search(params, data, OptionalThing.empty()); // A search request rewriter can send the search elsewhere (e.g. a bang to another @@ -115,10 +122,11 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r ComponentUtil.getV2EnvelopeWriter().writeSuccess(response, payload); return; } - final Map payload = buildPayload(params.getQuery(), data); + final OptionalThing userBean = getSavedUserBean(); + final String userId = userBean.map(FessUserBean::getUserId).filter(StringUtil::isNotBlank).orElse(null); + final Map payload = buildPayload(params.getQuery(), data, getVisibleTagTypes(data, userId), userId); // Evaluated per search: a user whose group and role permissions are still loading or // failed sees fewer results. - final OptionalThing userBean = getSavedUserBean(); payload.put("permission_state", ComponentUtil.getV2UserPayloads().permissionState(userBean.isPresent() ? userBean.get() : null)); ComponentUtil.getV2EnvelopeWriter().writeSuccess(response, payload); @@ -142,6 +150,67 @@ public void handle(final HttpServletRequest request, final HttpServletResponse r } } + /** + * Creates the request parameters of the search. The tag field is fetched while user tags are + * enabled so that the hits can carry the tags the caller can see; it is added here rather than + * to the global response fields, which every other API returns as is. + * + * @param request the incoming HTTP request + * @param fessConfig the Fess configuration + * @return the request parameters + */ + protected V2JsonRequestParams createRequestParams(final HttpServletRequest request, final FessConfig fessConfig) { + return new V2JsonRequestParams(request, fessConfig) { + @Override + public String[] getResponseFields() { + final String[] fields = super.getResponseFields(); + if (!fessConfig.isUserTagEnabled()) { + return fields; + } + final Set fieldSet = new LinkedHashSet<>(Arrays.asList(fields)); + fieldSet.add(fessConfig.getIndexFieldTag()); + return fieldSet.toArray(new String[0]); + } + }; + } + + /** + * Returns the tags of the hits and the tag facet that the caller can see, looked up in one call. + * Nobody who is not logged in sees a tag. + * + * @param data the search result + * @param userId the logged-in user, or null + * @return the visible tag types keyed by tag value + */ + Map getVisibleTagTypes(final SearchRenderData data, final String userId) { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + if (userId == null || !fessConfig.isUserTagEnabled()) { + return Collections.emptyMap(); + } + final String tagField = fessConfig.getIndexFieldTag(); + final Set values = new LinkedHashSet<>(); + final List> docs = data.getDocumentItems(); + if (docs != null) { + docs.forEach(doc -> values.addAll(toStringList(doc.get(tagField)))); + } + final FacetResponse facetResponse = data.getFacetResponse(); + if (facetResponse != null && facetResponse.getFieldList() != null) { + facetResponse.getFieldList() + .stream() + .filter(field -> tagField.equals(field.getName())) + .forEach(field -> values.addAll(field.getValueCountMap().keySet())); + } + if (values.isEmpty()) { + return Collections.emptyMap(); + } + try { + return ComponentUtil.getTagTypeHelper().getVisibleTagTypes(values, SearchRequestType.JSON); + } catch (final RuntimeException e) { + logger.warn("Failed to resolve the visible tags; the hits are returned without tags.", e); + return Collections.emptyMap(); + } + } + /** * Resolves the logged-in user. A seam so tests supply a user without the login subsystem; * a failed lookup counts as a guest. @@ -169,9 +238,12 @@ protected OptionalThing getSavedUserBean() { * * @param query the original {@code q} parameter (may be {@code null}) * @param data the populated render data returned by the search helper + * @param visibleTagTypes the tag types the caller can see, keyed by tag value + * @param userId the logged-in user, or null * @return the ordered payload map ready for envelope serialization */ - private Map buildPayload(final String query, final SearchRenderData data) { + private Map buildPayload(final String query, final SearchRenderData data, final Map visibleTagTypes, + final String userId) { final RelatedQueryHelper relatedQueryHelper = ComponentUtil.getRelatedQueryHelper(); final RelatedContentHelper relatedContentHelper = ComponentUtil.getRelatedContentHelper(); @@ -198,11 +270,11 @@ private Map buildPayload(final String query, final SearchRenderD payload.put("requested_time", data.getRequestedTime()); payload.put("related_query", relatedQueryHelper.getRelatedQueries(query)); payload.put("related_contents", relatedContentHelper.getRelatedContents(query)); - payload.put("data", filterDocuments(data.getDocumentItems())); + payload.put("data", filterDocuments(data.getDocumentItems(), visibleTagTypes, userId)); final FacetResponse facetResponse = data.getFacetResponse(); if (facetResponse != null && facetResponse.hasFacetResponse()) { - payload.put("facet_field", buildFacetField(facetResponse)); + payload.put("facet_field", buildFacetField(facetResponse, visibleTagTypes, userId)); payload.put("facet_query", buildFacetQuery(facetResponse)); } return payload; @@ -215,28 +287,74 @@ private Map buildPayload(final String query, final SearchRenderD *

    Drops blank keys and {@code null} values — matching v1's behavior so the * wire payload size stays stable across versions.

    * + *

    The raw {@code tag} field is never returned, even when + * {@code query.additional.api.response.fields} lists it: its values decode to the name and the + * owner of every tag on the document. The tags the caller can see are returned as {@code tags}, + * a list of {@code {value, name, owner, mine, shared}}, on a hit that has at least one.

    + * * @param docs the raw document items from {@link SearchRenderData} + * @param visibleTagTypes the tag types the caller can see, keyed by tag value + * @param userId the logged-in user, or null * @return a new list of filtered, order-preserved document maps */ - private List> filterDocuments(final List> docs) { + List> filterDocuments(final List> docs, final Map visibleTagTypes, + final String userId) { if (docs == null || docs.isEmpty()) { return new ArrayList<>(0); } final QueryFieldConfig cfg = ComponentUtil.getQueryFieldConfig(); + final String tagField = ComponentUtil.getFessConfig().getIndexFieldTag(); final List> out = new ArrayList<>(docs.size()); for (final Map doc : docs) { final Map filtered = new LinkedHashMap<>(); for (final Map.Entry e : doc.entrySet()) { final String name = e.getKey(); - if (StringUtil.isNotBlank(name) && e.getValue() != null && cfg.isApiResponseField(name)) { + if (StringUtil.isNotBlank(name) && e.getValue() != null && cfg.isApiResponseField(name) && !tagField.equals(name)) { filtered.put(name, e.getValue()); } } + if (!visibleTagTypes.isEmpty()) { + final List> tags = new ArrayList<>(); + for (final String value : toStringList(doc.get(tagField))) { + final TagType tagType = visibleTagTypes.get(value); + if (tagType != null) { + final Map tag = new LinkedHashMap<>(); + tag.put("value", value); + tag.put("name", tagType.getName()); + putOwnership(tag, tagType, userId); + tags.add(tag); + } + } + if (!tags.isEmpty()) { + filtered.put("tags", tags); + } + } out.add(filtered); } return out; } + /** Adds {@code owner}, {@code mine} and {@code shared} of a tag. */ + private static void putOwnership(final Map map, final TagType tagType, final String userId) { + map.put("owner", tagType.getOwner()); + map.put("mine", userId != null && userId.equals(tagType.getOwner())); + map.put("shared", ComponentUtil.getTagTypeHelper().isShared(tagType)); + } + + private static List toStringList(final Object value) { + final List list = new ArrayList<>(); + if (value instanceof final Collection collection) { + collection.forEach(v -> list.add(String.valueOf(v))); + } else if (value instanceof final Object[] array) { + for (final Object v : array) { + list.add(String.valueOf(v)); + } + } else if (value != null) { + list.add(value.toString()); + } + return list; + } + /** * Builds the {@code facet_field} array — one entry per facet field with the * field name and the value/count pairs nested inside a {@code result} array. @@ -248,6 +366,22 @@ private List> filterDocuments(final List * @return a list of {@code {name, result:[{value, count}]}} maps */ List> buildFacetField(final FacetResponse facetResponse) { + return buildFacetField(facetResponse, Collections.emptyMap(), null); + } + + /** + * Builds the {@code facet_field} array. A bucket of the {@code tag} facet is kept only for a tag + * the caller can see and carries its name as {@code label} with {@code owner}, {@code mine} and + * {@code shared}. + * + * @param facetResponse the populated facet response (never {@code null}) + * @param visibleTagTypes the tag types the caller can see, keyed by tag value + * @param userId the logged-in user, or null + * @return a list of {@code {name, result:[{value, count}]}} maps + */ + List> buildFacetField(final FacetResponse facetResponse, final Map visibleTagTypes, + final String userId) { + final String tagField = ComponentUtil.getFessConfig().getIndexFieldTag(); final List fields = facetResponse.getFieldList(); if (fields == null || fields.isEmpty()) { return new ArrayList<>(0); @@ -257,10 +391,19 @@ List> buildFacetField(final FacetResponse facetResponse) { final Map entry = new LinkedHashMap<>(); entry.put("name", field.getName()); final List> results = new ArrayList<>(); + final boolean isTagField = tagField.equals(field.getName()); for (final Map.Entry vc : field.getValueCountMap().entrySet()) { + final TagType tagType = isTagField ? visibleTagTypes.get(vc.getKey()) : null; + if (isTagField && tagType == null) { + continue; + } final Map result = new LinkedHashMap<>(); result.put("value", vc.getKey()); result.put("count", vc.getValue()); + if (tagType != null) { + result.put("label", tagType.getName()); + putOwnership(result, tagType, userId); + } results.add(result); } entry.put("result", results); diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java new file mode 100644 index 0000000000..4b6b127215 --- /dev/null +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java @@ -0,0 +1,240 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.fess.api.v2.V2ErrorCode; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Handles the tags of the logged-in user: {@code GET/POST /api/v2/tags} and {@code PUT/DELETE /api/v2/tags/{id}}. + * + *
      + *
    • {@code GET /tags} lists the caller's tags as {@code {id, value, name, shared, sort_order, path_count}}.
    • + *
    • {@code POST /tags} with {@code {"name": "...", "shared": false}} creates a tag. A tag of the same name of the + * caller is a {@link V2ErrorCode#CONFLICT}; a caller has at most {@code user.tag.max.tags} tags.
    • + *
    • {@code PUT /tags/{id}} with {@code {"name"?, "shared"?}} renames the tag, which gives it a new id and value + * and queues the rename of the value on the documents, and/or changes whether it is shared, which only changes + * who sees it.
    • + *
    • {@code DELETE /tags/{id}} deletes the tag and queues its removal from the documents.
    • + *
    + */ +public class TagsHandler extends AbstractTagHandler { + + private static final Logger logger = LogManager.getLogger(TagsHandler.class); + + /** + * Default constructor used by the DI container. The handler holds no per-request state. + */ + public TagsHandler() { + // no-op + } + + /** + * Processes one {@code /api/v2/tags} or {@code /api/v2/tags/{id}} request. + * + * @param req the incoming HTTP request + * @param res the HTTP response to write to + * @param tagId the tag id of {@code /tags/{id}}, or null for {@code /tags} + * @throws IOException if writing the envelope fails + */ + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String tagId) throws IOException { + final String method = req.getMethod() == null ? StringUtil.EMPTY : req.getMethod().toUpperCase(Locale.ROOT); + final boolean collection = tagId == null; + if (collection ? !"GET".equals(method) && !"POST".equals(method) : !"PUT".equals(method) && !"DELETE".equals(method)) { + res.setHeader("Allow", collection ? "GET, POST" : "PUT, DELETE"); + ComponentUtil.getV2EnvelopeWriter().writeError(res, V2ErrorCode.METHOD_NOT_ALLOWED, "method not allowed"); + return; + } + try { + final String userId = checkRequest(); + final Map payload = switch (method) { + case "GET" -> listTags(userId); + case "POST" -> createTag(req, userId); + case "PUT" -> updateTag(req, tagId, userId); + default -> deleteTag(tagId, userId); + }; + ComponentUtil.getV2EnvelopeWriter().writeSuccess(res, payload); + } catch (final TagRequestException e) { + ComponentUtil.getV2EnvelopeWriter().writeError(res, e.code, e.getMessage()); + } catch (final Exception e) { + ComponentUtil.getV2EnvelopeWriter().writeInternalError(res, e, logger, "/api/v2/tags " + method); + } + } + + private Map listTags(final String userId) { + final TagTypeService service = getTagTypeService(); + final Map pathCountMap = service.getPathCountMapByOwner(userId); + final List> tags = new ArrayList<>(); + for (final TagType tagType : service.getTagTypeListByOwner(userId)) { + final Long pathCount = pathCountMap.get(tagType.getName()); + tags.add(toTag(tagType, pathCount == null ? 0L : pathCount)); + } + final Map payload = new LinkedHashMap<>(); + payload.put("tags", tags); + return payload; + } + + private Map createTag(final HttpServletRequest req, final String userId) throws TagRequestException, IOException { + final Map body = readBody(req); + final String name = toTagName(body.get("name")); + final Boolean shared = toShared(body); + final TagType tagType = newTagType(name, userId, Boolean.TRUE.equals(shared), new String[0]); + try { + getTagTypeService().insert(tagType); + } catch (final TagTypeConflictException e) { + throw new TagRequestException(V2ErrorCode.CONFLICT, "a tag with the name already exists"); + } + final Map payload = new LinkedHashMap<>(); + payload.put("tag", toTag(tagType, 0L)); + return payload; + } + + private Map updateTag(final HttpServletRequest req, final String tagId, final String userId) + throws TagRequestException, IOException { + final Map body = readBody(req); + final String name = body.get("name") == null ? null : toTagName(body.get("name")); + final Boolean shared = toShared(body); + if (name == null && shared == null) { + throw new TagRequestException(V2ErrorCode.INVALID_REQUEST, "name or shared is required"); + } + final TagType current = getOwnTagType(tagId, userId); + final TagType result; + final boolean renamed = name != null && !name.equals(current.getName()); + if (renamed) { + result = renameTag(current, name, shared, userId); + } else if (shared == null) { + result = current; + } else { + result = updateTagType(tagId, userId, tagType -> { + final String[] permissions = applyShared(tagType.getPermissions(), tagType.getOwner(), shared); + if (Arrays.equals(permissions, tagType.getPermissions())) { + return false; + } + tagType.setPermissions(permissions); + return true; + }); + } + final Map payload = new LinkedHashMap<>(); + payload.put("tag", toTag(result, result.getPaths() == null ? 0L : result.getPaths().length)); + payload.put("renamed", renamed); + return payload; + } + + /** + * Renames a tag: the tag of the new name is created with the paths, permissions, virtual host and sort order of + * the old one, the old one is deleted, and the rename of the value on the documents is queued. When the caller + * has a tag of the new name already, nothing changes. + */ + private TagType renameTag(final TagType current, final String name, final Boolean shared, final String userId) + throws TagRequestException { + final TagTypeHelper helper = getTagTypeHelper(); + final TagType renamed = new TagType(); + renamed.setName(name); + renamed.setOwner(current.getOwner()); + renamed.setId(helper.toId(renamed.getTagValue())); + renamed.setPaths(current.getPaths()); + renamed.setPermissions( + shared == null ? current.getPermissions() : applyShared(current.getPermissions(), current.getOwner(), shared)); + renamed.setVirtualHost(current.getVirtualHost()); + renamed.setSortOrder(current.getSortOrder()); + renamed.setCreatedBy(current.getCreatedBy()); + renamed.setCreatedTime(current.getCreatedTime()); + renamed.setUpdatedBy(userId); + renamed.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); + try { + getTagTypeService().insert(renamed); + } catch (final TagTypeConflictException e) { + throw new TagRequestException(V2ErrorCode.CONFLICT, "a tag with the name already exists"); + } + getTagTypeService().delete(current); + enqueue(TagChange.rename(current.getTagValue(), renamed.getTagValue())); + return renamed; + } + + /** + * Adds the sharing roles to the permissions of a tag, or removes them. The owner's role and any + * permission an administrator added are kept. + * + * @param permissions the current permissions + * @param owner the owner of the tag + * @param shared whether the tag is to be shared + * @return the new permissions + */ + private String[] applyShared(final String[] permissions, final String owner, final boolean shared) { + final TagTypeHelper helper = getTagTypeHelper(); + final List ownList = Arrays.asList(helper.buildPermissions(owner, false)); + final List sharingList = new ArrayList<>(Arrays.asList(helper.buildPermissions(owner, true))); + sharingList.removeAll(ownList); + final List list = new ArrayList<>(); + ownList.forEach(p -> { + if (!list.contains(p)) { + list.add(p); + } + }); + if (permissions != null) { + for (final String p : permissions) { + if (!list.contains(p) && !sharingList.contains(p)) { + list.add(p); + } + } + } + if (shared) { + list.addAll(sharingList); + } + return list.toArray(String[]::new); + } + + private Map deleteTag(final String tagId, final String userId) throws TagRequestException { + final TagType tagType = getOwnTagType(tagId, userId); + getTagTypeService().delete(tagType); + enqueue(TagChange.delete(tagType.getTagValue())); + final Map payload = new LinkedHashMap<>(); + payload.put("id", tagId); + payload.put("deleted", true); + return payload; + } + + private Map toTag(final TagType tagType, final long pathCount) { + final String value = tagType.getTagValue(); + final Map map = new LinkedHashMap<>(); + map.put("id", getTagTypeHelper().toId(value)); + map.put("value", value); + map.put("name", tagType.getName()); + map.put("shared", getTagTypeHelper().isShared(tagType)); + map.put("sort_order", tagType.getSortOrder() == null ? 0 : tagType.getSortOrder()); + map.put("path_count", pathCount); + return map; + } +} diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java index e8fb0d9b13..b6bd2e1449 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/UiConfigHandler.java @@ -423,6 +423,9 @@ public void handle(final HttpServletRequest req, final HttpServletResponse res) features.put("search_history", cfg.isSearchHistoryEnabled() && searchLogEnabled); // search_export: whether GET /api/v2/documents/export is available, so the theme shows the export links. features.put("search_export", cfg.isApiSearchExport()); + // user_tag: whether logged-in users can manage their tags and put them on documents + // (/api/v2/tags and /api/v2/documents/{doc_id}/tags), and whether hits carry tags. + features.put("user_tag", cfg.isUserTagEnabled()); features.put("popular_word", cfg.isWebApiPopularWord()); features.put("suggest_search_log", cfg.isSuggestSearchLog()); features.put("suggest_documents", cfg.isSuggestDocuments()); diff --git a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java index ad84daf570..f32bff5aa3 100644 --- a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java +++ b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java @@ -15,14 +15,20 @@ */ package org.codelibs.fess.app.service; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.codelibs.core.beans.util.BeanUtil; import org.codelibs.core.lang.StringUtil; +import org.codelibs.fesen.opensearch.search.aggregations.Aggregations; +import org.codelibs.fesen.opensearch.search.aggregations.bucket.terms.Terms; +import org.codelibs.fesen.opensearch.search.aggregations.metrics.ValueCount; import org.codelibs.fess.Constants; import org.codelibs.fess.app.pager.TagTypePager; import org.codelibs.fess.exception.TagTypeConflictException; import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.allcommon.EsPagingResultBean; import org.codelibs.fess.opensearch.config.cbean.TagTypeCB; import org.codelibs.fess.opensearch.config.exbhv.TagTypeBhv; import org.codelibs.fess.opensearch.config.exentity.TagType; @@ -43,6 +49,9 @@ */ public class TagTypeService extends FessAppService { + /** The name of the aggregations that count the paths of each tag. */ + private static final String PATH_COUNT_AGGREGATION = "path_count"; + /** The TagType behavior. */ @Resource protected TagTypeBhv tagTypeBhv; @@ -130,6 +139,32 @@ public List getTagTypeListByOwner(final String owner) { }); } + /** + * Count the paths of each tag type of one owner without reading the paths: a terms + * aggregation on the name, unique per owner, with a value count of the paths. + * + * @param owner The owner of the tag types. + * @return The number of paths keyed by tag name; a tag without paths may be missing. + */ + public Map getPathCountMapByOwner(final String owner) { + final int maxTags = fessConfig.getUserTagMaxTagsAsInteger(); + final EsPagingResultBean result = (EsPagingResultBean) tagTypeBhv.selectPage(cb -> { + cb.fetchFirst(0); + cb.query().setOwner_Term(owner); + cb.aggregation() + .setName_Terms(PATH_COUNT_AGGREGATION, op -> op.size(maxTags), ca -> ca.setPaths_Count(PATH_COUNT_AGGREGATION, null)); + }); + final Map pathCountMap = new HashMap<>(); + final Aggregations aggregations = result.getAggregations(); + if (aggregations != null && aggregations.get(PATH_COUNT_AGGREGATION) instanceof final Terms terms) { + for (final Terms.Bucket bucket : terms.getBuckets()) { + final ValueCount valueCount = bucket.getAggregations().get(PATH_COUNT_AGGREGATION); + pathCountMap.put(bucket.getKeyAsString(), valueCount == null ? 0L : valueCount.getValue()); + } + } + return pathCountMap; + } + /** * Count the tag types of one owner. * diff --git a/src/main/resources/fess_api.xml b/src/main/resources/fess_api.xml index 0a607dfbd5..550972a4f2 100644 --- a/src/main/resources/fess_api.xml +++ b/src/main/resources/fess_api.xml @@ -25,6 +25,8 @@ + + diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java index 7acb3e3f20..aa1edb527b 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerCsrfTest.java @@ -144,6 +144,67 @@ public void test_postFavoriteRejectedWithoutCsrfToken_returns403() throws Except assertTrue(body.contains("\"code\":\"forbidden\""), body); } + @Test + public void test_tagWritesRejectedWithoutCsrfToken_returns403() throws Exception { + final String[][] requests = { { "POST", "/api/v2/tags" }, { "PUT", "/api/v2/tags/abc" }, { "DELETE", "/api/v2/tags/abc" }, + { "POST", "/api/v2/documents/abc123/tags" }, { "DELETE", "/api/v2/documents/abc123/tags/abc" } }; + for (final String[] r : requests) { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordTagCalls(m); + final CapturingResponse res = new CapturingResponse(); + m.process(new StubRequest(r[1]).withMethod(r[0]), res, new NopChain()); + assertEquals(403, res.status, r[0] + " " + r[1]); + assertTrue(res.body().contains("\"code\":\"forbidden\""), res.body()); + assertTrue(calls.isEmpty(), r[0] + " " + r[1] + " must be stopped before the handler"); + } + } + + @Test + public void test_tagWritesWithValidCsrfToken_reachTheHandlers() throws Exception { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordTagCalls(m); + final String[][] requests = { { "POST", "/api/v2/tags" }, { "PUT", "/api/v2/tags/abc" }, { "DELETE", "/api/v2/tags/abc" }, + { "POST", "/api/v2/documents/abc123/tags" }, { "DELETE", "/api/v2/documents/abc123/tags/abc" } }; + for (final String[] r : requests) { + final StubSession session = new StubSession(); + session.setAttribute(SessionCsrfTokenManager.SESSION_ATTR, "the-valid-token"); + final CapturingResponse res = new CapturingResponse(); + m.process(new StubRequest(r[1]).withMethod(r[0]).withSession(session).withHeader("X-Fess-CSRF-Token", "the-valid-token"), res, + new NopChain()); + assertFalse(res.body().contains("\"code\":\"forbidden\""), res.body()); + } + assertEquals(java.util.List.of("tags POST null", "tags PUT abc", "tags DELETE abc", "documents POST abc123 null", + "documents DELETE abc123 abc"), calls); + } + + @Test + public void test_tagReadsWithoutTokenAreExempt() throws Exception { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordTagCalls(m); + m.process(new StubRequest("/api/v2/tags"), new CapturingResponse(), new NopChain()); + m.process(new StubRequest("/api/v2/documents/abc123/tags"), new CapturingResponse(), new NopChain()); + assertEquals(java.util.List.of("tags GET null", "documents GET abc123 null"), calls); + } + + /** Replaces both tag handlers with ones that record each request they receive. */ + private static java.util.List recordTagCalls(final SearchApiV2Manager m) { + final java.util.List calls = new java.util.ArrayList<>(); + m.tagsHandler = new org.codelibs.fess.api.v2.handlers.TagsHandler() { + @Override + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String tagId) throws IOException { + calls.add("tags " + req.getMethod() + " " + tagId); + } + }; + m.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { + @Override + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId, final String tagId) + throws IOException { + calls.add("documents " + req.getMethod() + " " + docId + " " + tagId); + } + }; + return calls; + } + @Test public void test_postChatRejectedWithoutCsrfToken_returns403() throws Exception { final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java index ea85596f38..471176bc31 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerLoginRequiredTest.java @@ -225,7 +225,8 @@ public void test_search_isRejectedWithExpiredAccessTokenWhenLoginRequired() thro public void test_sessionOnlyEndpoints_stayGatedWithValidAccessTokenWhenLoginRequired() throws Exception { setLoginRequired(true); for (final String path : new String[] { "/api/v2/chat", "/api/v2/favorites", "/api/v2/search-history", "/api/v2/click", - "/api/v2/cache/abc123", "/api/v2/related-queries", "/api/v2/related-content", "/api/v2/auth/password" }) { + "/api/v2/cache/abc123", "/api/v2/related-queries", "/api/v2/related-content", "/api/v2/auth/password", "/api/v2/tags", + "/api/v2/tags/abc", "/api/v2/documents/abc123/tags", "/api/v2/documents/abc123/tags/abc" }) { final CapturingResponse res = processWithTokens(path, "Bearer " + VALID_TOKEN); assertEquals(401, res.status, path); assertTrue(res.body().contains("login required"), path + " -> " + res.body()); diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java index 2c921e209e..41bed3035f 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTest.java @@ -244,6 +244,82 @@ public void test_process_documentsFavoriteRejectsMalformedDocId() throws Excepti assertTrue(body.contains("invalid doc_id"), body); } + /** Replaces the document tags handler with one that records "docId|tagId" for each request. */ + private static java.util.List recordDocumentTagsCalls(final SearchApiV2Manager m) { + final java.util.List calls = new java.util.ArrayList<>(); + m.documentTagsHandler = new org.codelibs.fess.api.v2.handlers.DocumentTagsHandler() { + @Override + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String docId, final String tagId) + throws IOException { + calls.add(docId + "|" + tagId); + } + }; + return calls; + } + + /** Replaces the tags handler with one that records the tag id of each request. */ + private static java.util.List recordTagsCalls(final SearchApiV2Manager m) { + final java.util.List calls = new java.util.ArrayList<>(); + m.tagsHandler = new org.codelibs.fess.api.v2.handlers.TagsHandler() { + @Override + public void handle(final HttpServletRequest req, final HttpServletResponse res, final String tagId) throws IOException { + calls.add(String.valueOf(tagId)); + } + }; + return calls; + } + + @Test + public void test_process_documentTagsRoutesToDocumentTagsHandler() throws Exception { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordDocumentTagsCalls(m); + m.process(new StubRequest("/api/v2/documents/abc123/tags"), new CapturingResponse(), new NopChain()); + m.process(new StubRequest("/api/v2/documents/abc123/tags/"), new CapturingResponse(), new NopChain()); + m.process(new StubRequest("/api/v2/documents/abc123/tags/t1"), new CapturingResponse(), new NopChain()); + // "all" and "export" are only matched exactly, so they are plain doc ids here. + m.process(new StubRequest("/api/v2/documents/all/tags"), new CapturingResponse(), new NopChain()); + assertEquals(java.util.List.of("abc123|null", "abc123|null", "abc123|t1", "all|null"), calls); + } + + @Test + public void test_process_documentTagsWithoutADocIdOrWithExtraSegmentsIsNotFound() throws Exception { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordDocumentTagsCalls(m); + for (final String path : new String[] { "/api/v2/documents/tags", "/api/v2/documents//tags", "/api/v2/documents/abc/tags/t1/x", + "/api/v2/documents/abc/tagsx" }) { + final CapturingResponse res = new CapturingResponse(); + m.process(new StubRequest(path), res, new NopChain()); + assertEquals(404, res.status, path); + assertTrue(res.body().contains("\"code\":\"not_found\""), res.body()); + } + assertTrue(calls.isEmpty(), calls.toString()); + } + + @Test + public void test_process_documentTagsRejectsMalformedDocId() throws Exception { + // Reaches the real handler, which validates the doc id before it needs any other component. + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final CapturingResponse res = new CapturingResponse(); + m.process(new StubRequest("/api/v2/documents/has spaces/tags"), res, new NopChain()); + assertEquals(400, res.status); + assertTrue(res.body().contains("invalid doc_id"), res.body()); + } + + @Test + public void test_process_tagsRoutesToTagsHandler() throws Exception { + final SearchApiV2Manager m = SearchApiV2ManagerTestSupport.newManagerWithHandlers(); + final java.util.List calls = recordTagsCalls(m); + m.process(new StubRequest("/api/v2/tags"), new CapturingResponse(), new NopChain()); + m.process(new StubRequest("/api/v2/tags/"), new CapturingResponse(), new NopChain()); + m.process(new StubRequest("/api/v2/tags/abc"), new CapturingResponse(), new NopChain()); + assertEquals(java.util.List.of("null", "null", "abc"), calls); + + final CapturingResponse res = new CapturingResponse(); + m.process(new StubRequest("/api/v2/tags/abc/def"), res, new NopChain()); + assertEquals(404, res.status); + assertEquals(3, calls.size()); + } + @Test public void test_process_labelsRejectsPostAtCsrfGate() throws Exception { // Per spec §7.3, an anonymous client POSTing to a GET-only endpoint like /labels diff --git a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java index 8cd9df9442..8333835311 100644 --- a/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java +++ b/src/test/java/org/codelibs/fess/api/v2/SearchApiV2ManagerTestSupport.java @@ -20,6 +20,7 @@ import org.codelibs.fess.api.v2.handlers.ChatSessionClearHandler; import org.codelibs.fess.api.v2.handlers.ChatStreamHandler; import org.codelibs.fess.api.v2.handlers.ClickHandler; +import org.codelibs.fess.api.v2.handlers.DocumentTagsHandler; import org.codelibs.fess.api.v2.handlers.ExportSearchHandler; import org.codelibs.fess.api.v2.handlers.FavoriteGetHandler; import org.codelibs.fess.api.v2.handlers.FavoritePostHandler; @@ -36,6 +37,7 @@ import org.codelibs.fess.api.v2.handlers.SearchHandler; import org.codelibs.fess.api.v2.handlers.SearchHistoryHandler; import org.codelibs.fess.api.v2.handlers.SuggestWordsHandler; +import org.codelibs.fess.api.v2.handlers.TagsHandler; import org.codelibs.fess.api.v2.handlers.TargetOriginResolver; import org.codelibs.fess.api.v2.handlers.UiConfigHandler; import org.codelibs.fess.cors.CorsHandlerFactory; @@ -83,6 +85,8 @@ static SearchApiV2Manager newManagerWithHandlers() { m.favoriteGetHandler = new FavoriteGetHandler(); m.favoritePostHandler = new FavoritePostHandler(); m.favoritesListHandler = new FavoritesListHandler(); + m.tagsHandler = new TagsHandler(); + m.documentTagsHandler = new DocumentTagsHandler(); m.searchHistoryHandler = new SearchHistoryHandler(); m.meHandler = new MeHandler(); m.logoutHandler = new LogoutHandler(); diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java index 06fb29d07e..aa4dacd7eb 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/CsrfRequirementCompleteCoverageTest.java @@ -68,7 +68,7 @@ public class CsrfRequirementCompleteCoverageTest { * Must equal {@code ENDPOINT_DECISIONS.size()}. Increment when adding a new endpoint * so that the mismatch causes a deliberate compile-time / test-time notice. */ - private static final int EXPECTED_ENTRY_COUNT = 18; + private static final int EXPECTED_ENTRY_COUNT = 22; static { // LinkedHashMap preserves insertion order for readable failure messages. @@ -101,6 +101,12 @@ public class CsrfRequirementCompleteCoverageTest { m.put("/favorites", true); // GET only — secure default (CSRF required if called via POST) m.put("/search-history", true); // GET only — secure default (CSRF required if called via POST) + // --- USER TAG endpoints (POST/PUT/DELETE change the caller's tags) --- + m.put("/tags", true); // POST, CSRF required + m.put("/tags/abc", true); // PUT/DELETE, CSRF required + m.put("/documents/abc123/tags", true); // POST, CSRF required + m.put("/documents/abc123/tags/abc", true); // DELETE, CSRF required + // --- CHAT endpoints --- m.put("/chat", true); // POST, CSRF required m.put("/chat/stream", true); // POST, CSRF required @@ -151,6 +157,14 @@ public void test_stateChangingEndpointsAreAllCsrfRequired() { for (final String path : stateChangingPost) { assertTrue(new CsrfRequirement().requiresCsrf(path, "POST"), "State-changing endpoint must require CSRF token: POST " + path); } + for (final String path : Arrays.asList("/tags", "/documents/abc123/tags")) { + assertTrue(new CsrfRequirement().requiresCsrf(path, "POST"), "State-changing endpoint must require CSRF token: POST " + path); + } + assertTrue(new CsrfRequirement().requiresCsrf("/tags/abc", "PUT"), "PUT /tags/{id} must require CSRF token"); + for (final String path : Arrays.asList("/tags/abc", "/documents/abc123/tags/abc")) { + assertTrue(new CsrfRequirement().requiresCsrf(path, "DELETE"), + "State-changing endpoint must require CSRF token: DELETE " + path); + } // DELETE /chat/sessions/{session_id} is state-changing — CSRF required assertTrue(new CsrfRequirement().requiresCsrf("/chat/sessions/abc", "DELETE"), "DELETE /chat/sessions/{session_id} must require CSRF token"); diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java new file mode 100644 index 0000000000..827b6da450 --- /dev/null +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/DocumentTagsHandlerTest.java @@ -0,0 +1,308 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import static org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.docTag; +import static org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.list; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.Env; +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.Response; +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.StubRequest; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.dbflute.optional.OptionalThing; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +/** + * Unit tests for {@link DocumentTagsHandler}: {@code GET/POST /api/v2/documents/{docId}/tags} and + * {@code DELETE /api/v2/documents/{docId}/tags/{id}}. + */ +public class DocumentTagsHandlerTest extends UnitFessTestCase { + + private static final String DOC_ID = "doc1"; + + private static final String URL = "http://example.com/a b%20#x"; + + private Env env; + + /** The document the caller can see, or null when it cannot. */ + private Map doc; + + private final List documentLookups = new ArrayList<>(); + + @Override + protected void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + env = new Env(); + doc = new HashMap<>(Map.of("url", URL)); + documentLookups.clear(); + } + + private class StubHandler extends DocumentTagsHandler { + @Override + protected OptionalThing getUserBean() { + return env.user; + } + + @Override + protected TagTypeService getTagTypeService() { + return env.service; + } + + @Override + protected Map getDocument(final String docId, final String[] fields) { + documentLookups.add(docId); + return doc; + } + } + + private Response call(final StubRequest req, final String tagId) throws IOException { + final Response res = new Response(); + new StubHandler().handle(req.proxy(), res.proxy(), DOC_ID, tagId); + return res; + } + + private static StubRequest request(final String method) { + return new StubRequest(method); + } + + // =================================================================================== + // Request gates + // ============= + + @Test + public void test_disabled_returnsInvalidRequestForEveryMethod() throws Exception { + env.fessConfig.enabled = false; + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, URL); + final String[][] requests = { { "GET", null }, { "POST", null }, { "DELETE", tag.getId() } }; + for (final String[] r : requests) { + final Response res = call(request(r[0]).json("{\"name\":\"foo\"}"), r[1]); + Assertions.assertEquals(400, res.status, r[0]); + Assertions.assertEquals("tag feature is not available", res.errorMessage(), r[0]); + } + Assertions.assertEquals(List.of(), documentLookups); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_anonymous_returnsAuthRequired() throws Exception { + final TagType tag = env.put("foo", "alice", true, URL); + final String[][] requests = { { "GET", null }, { "POST", null }, { "DELETE", tag.getId() } }; + for (final String[] r : requests) { + final Response res = call(request(r[0]).json("{\"name\":\"foo\"}"), r[1]); + Assertions.assertEquals(401, res.status, r[0]); + Assertions.assertEquals("auth_required", res.errorCode(), r[0]); + } + Assertions.assertEquals(List.of(), documentLookups); + } + + @Test + public void test_accessTokenWithoutLogin_isRefused() throws Exception { + final Response res = call(request("POST").header("Authorization", "Bearer token").json("{\"name\":\"foo\"}"), null); + Assertions.assertEquals(401, res.status); + Assertions.assertEquals("auth_required", res.errorCode()); + Assertions.assertEquals(List.of(), env.service.calls); + } + + @Test + public void test_unsupportedMethods_return405() throws Exception { + env.user("alice"); + Response res = call(request("DELETE"), null); + Assertions.assertEquals(405, res.status); + Assertions.assertEquals("GET, POST", res.headers.get("Allow")); + res = call(request("GET"), "abc"); + Assertions.assertEquals(405, res.status); + Assertions.assertEquals("DELETE", res.headers.get("Allow")); + } + + @Test + public void test_invalidDocId_returnsInvalidRequest() throws Exception { + env.user("alice"); + final Response res = new Response(); + new StubHandler().handle(request("GET").proxy(), res.proxy(), "a b", null); + Assertions.assertEquals(400, res.status); + Assertions.assertEquals("invalid doc_id", res.errorMessage()); + } + + @Test + public void test_invisibleDoc_returnsNotFound() throws Exception { + env.user("alice"); + doc = null; + final TagType tag = env.put("foo", "alice", false); + final String[][] requests = { { "GET", null }, { "POST", null }, { "DELETE", tag.getId() } }; + for (final String[] r : requests) { + final Response res = call(request(r[0]).json("{\"name\":\"foo\"}"), r[1]); + Assertions.assertEquals(404, res.status, r[0]); + Assertions.assertEquals("doc not found: " + DOC_ID, res.errorMessage(), r[0]); + } + Assertions.assertEquals(List.of(), env.helper.changes); + Assertions.assertEquals(0, env.stored("foo", "alice").getPaths() == null ? 0 : env.stored("foo", "alice").getPaths().length); + } + + // =================================================================================== + // GET + // === + + @Test + public void test_get_listsVisibleTagsAndOwnAddableTags() throws Exception { + env.user("alice"); + final TagType own = env.put("mine", "alice", false, URL); + final TagType othersShared = env.put("theirs", "bob", true, URL, "http://other/"); + env.put("hidden", "bob", false, URL); + final TagType addable = env.put("spare", "alice", true, "http://other/"); + env.put("unrelated", "bob", true, "http://other/"); + final Response res = call(request("GET"), null); + Assertions.assertEquals(200, res.status, res.body()); + final Map payload = res.payload(); + Assertions.assertEquals(DOC_ID, payload.get("doc_id")); + Assertions.assertEquals(List.of(docTag(own, true, false), docTag(othersShared, false, true)), payload.get("tags")); + Assertions.assertEquals(List.of(docTag(addable, true, true)), payload.get("addable")); + Assertions.assertEquals(List.of(DOC_ID), documentLookups); + } + + // =================================================================================== + // POST + // ==== + + @Test + public void test_postByName_autoCreatesPrivateTagAndEnqueuesAdd() throws Exception { + env.user("alice"); + final Response res = call(request("POST").json("{\"name\":\"new tag\"}"), null); + Assertions.assertEquals(200, res.status, res.body()); + final TagType created = env.stored("new tag", "alice"); + Assertions.assertNotNull(created); + Assertions.assertEquals(list(URL), list(created.getPaths())); + Assertions.assertEquals(list("1alice"), list(created.getPermissions())); + final Map payload = res.payload(); + Assertions.assertEquals(Boolean.TRUE, payload.get("added")); + Assertions.assertEquals(docTag(created, true, false), payload.get("tag")); + Assertions.assertEquals(List.of(docTag(created, true, false)), payload.get("tags")); + Assertions.assertEquals(List.of(TagChange.add(created.getTagValue(), URL)), env.helper.changes); + } + + @Test + public void test_postByName_existingTagGetsTheUrl() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", true, "http://other/"); + final Response res = call(request("POST").json("{\"name\":\"foo\"}"), null); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(list("http://other/", URL), list(env.service.store.get(tag.getId()).getPaths())); + Assertions.assertEquals(List.of(TagChange.add(tag.getTagValue(), URL)), env.helper.changes); + // adding it again changes nothing + final Response again = call(request("POST").json("{\"id\":\"" + tag.getId() + "\"}"), null); + Assertions.assertEquals(200, again.status, again.body()); + Assertions.assertEquals(Boolean.FALSE, again.payload().get("added")); + Assertions.assertEquals(list("http://other/", URL), list(env.service.store.get(tag.getId()).getPaths())); + Assertions.assertEquals(1, env.helper.changes.size()); + } + + @Test + public void test_postByName_autoCreateRespectsMaxTags() throws Exception { + env.user("alice"); + env.fessConfig.maxTags = 1; + env.put("a", "alice", false); + final Response res = call(request("POST").json("{\"name\":\"b\"}"), null); + Assertions.assertEquals(400, res.status); + Assertions.assertEquals("too many tags: a user can have up to 1 tags", res.errorMessage()); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_post_maxPathsReached_returnsInvalidRequest() throws Exception { + env.user("alice"); + env.fessConfig.maxPaths = 2; + final TagType tag = env.put("foo", "alice", false, "http://a/", "http://b/"); + final Response res = call(request("POST").json("{\"id\":\"" + tag.getId() + "\"}"), null); + Assertions.assertEquals(400, res.status, res.body()); + Assertions.assertEquals("invalid_request", res.errorCode()); + Assertions.assertEquals("too many documents: a tag can be put on up to 2 documents", res.errorMessage()); + Assertions.assertEquals(list("http://a/", "http://b/"), list(env.service.store.get(tag.getId()).getPaths())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_post_retriesAConcurrentUpdate() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + env.service.updateConflicts = 1; + final Response res = call(request("POST").json("{\"id\":\"" + tag.getId() + "\"}"), null); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(list("http://a/", URL), list(env.service.store.get(tag.getId()).getPaths())); + } + + @Test + public void test_postOthersTag_returnsForbiddenOrNotFound() throws Exception { + env.user("alice"); + final TagType shared = env.put("foo", "bob", true); + final TagType hidden = env.put("bar", "bob", false); + Response res = call(request("POST").json("{\"id\":\"" + shared.getId() + "\"}"), null); + Assertions.assertEquals(403, res.status, res.body()); + res = call(request("POST").json("{\"id\":\"" + hidden.getId() + "\"}"), null); + Assertions.assertEquals(404, res.status, res.body()); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_post_withoutNameOrId_returnsInvalidRequest() throws Exception { + env.user("alice"); + final Response res = call(request("POST").json("{}"), null); + Assertions.assertEquals(400, res.status); + Assertions.assertEquals("name or id is required", res.errorMessage()); + } + + // =================================================================================== + // DELETE + // ====== + + @Test + public void test_delete_removesTheUrlAndEnqueuesRemove() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/", URL); + final Response res = call(request("DELETE"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(Boolean.TRUE, res.payload().get("removed")); + Assertions.assertEquals(List.of(), res.payload().get("tags")); + Assertions.assertEquals(list("http://a/"), list(env.service.store.get(tag.getId()).getPaths())); + Assertions.assertEquals(List.of(TagChange.remove(tag.getTagValue(), URL)), env.helper.changes); + + final Response again = call(request("DELETE"), tag.getId()); + Assertions.assertEquals(200, again.status, again.body()); + Assertions.assertEquals(Boolean.FALSE, again.payload().get("removed")); + Assertions.assertEquals(1, env.helper.changes.size()); + } + + @Test + public void test_deleteOthersTag_returnsForbidden() throws Exception { + env.user("alice"); + final TagType shared = env.put("foo", "bob", true, URL); + final Response res = call(request("DELETE"), shared.getId()); + Assertions.assertEquals(403, res.status, res.body()); + Assertions.assertEquals(list(URL), list(env.service.store.get(shared.getId()).getPaths())); + Assertions.assertEquals(List.of(), env.helper.changes); + } +} diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java index 2bfc21bc92..5ca7e4775f 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/LoginRequirementCompleteCoverageTest.java @@ -50,7 +50,7 @@ public class LoginRequirementCompleteCoverageTest { private static final Map ENDPOINT_DECISIONS; /** Must equal {@code ENDPOINT_DECISIONS.size()}. */ - private static final int EXPECTED_ENTRY_COUNT = 20; + private static final int EXPECTED_ENTRY_COUNT = 24; static { final Map m = new LinkedHashMap<>(); @@ -81,6 +81,10 @@ public class LoginRequirementCompleteCoverageTest { m.put("/search-history", true); m.put("/documents/abc123/favorite", true); m.put("/chat", true); + m.put("/tags", true); + m.put("/tags/abc", true); + m.put("/documents/abc123/tags", true); + m.put("/documents/abc123/tags/abc", true); ENDPOINT_DECISIONS = Collections.unmodifiableMap(m); } diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java index 2aaf1bc3b3..3c7fe366b1 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/SearchHandlerTest.java @@ -34,7 +34,9 @@ import org.codelibs.fess.helper.RelatedContentHelper; import org.codelibs.fess.helper.RelatedQueryHelper; import org.codelibs.fess.helper.SearchHelper; +import org.codelibs.fess.helper.TagTypeHelper; import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.opensearch.config.exentity.TagType; import org.codelibs.fess.unit.UnitFessTestCase; import org.codelibs.fess.util.ComponentUtil; import org.codelibs.fess.util.FacetResponse; @@ -478,6 +480,274 @@ protected OptionalThing getSavedUserBean() { assertTrue(body.contains("\"permission_state\":\"RESOLVED\""), body); } + // ===== User tags ===== + + /** Delegates to the container's configuration except for user.tag.enabled. */ + private static void setUserTagEnabled(final boolean enabled) { + final org.codelibs.fess.mylasta.direction.FessConfig real = ComponentUtil.getFessConfig(); + ComponentUtil.setFessConfig((org.codelibs.fess.mylasta.direction.FessConfig) java.lang.reflect.Proxy.newProxyInstance( + org.codelibs.fess.mylasta.direction.FessConfig.class.getClassLoader(), + new Class[] { org.codelibs.fess.mylasta.direction.FessConfig.class }, (proxy, method, args) -> { + if ("isUserTagEnabled".equals(method.getName())) { + return enabled; + } + try { + return method.invoke(real, args); + } catch (final java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + })); + } + + /** Registers the real response field configuration, in which the tag field is not a response field. */ + private static void registerQueryFieldConfig() { + final org.codelibs.fess.query.QueryFieldConfig queryFieldConfig = new org.codelibs.fess.query.QueryFieldConfig(); + queryFieldConfig.init(); + ComponentUtil.register(queryFieldConfig, "queryFieldConfig"); + } + + private static TagType tagType(final String name, final String owner, final boolean shared) { + final TagType tagType = new TagType(); + tagType.setName(name); + tagType.setOwner(owner); + tagType.setPermissions(shared ? new String[] { "1" + owner, "Rguest" } : new String[] { "1" + owner }); + return tagType; + } + + private static Map visibleMap(final TagType... tagTypes) { + final Map map = new LinkedHashMap<>(); + for (final TagType tagType : tagTypes) { + map.put(tagType.getTagValue(), tagType); + } + return map; + } + + private static Map tagItem(final TagType tagType, final boolean mine, final boolean shared) { + final Map item = new LinkedHashMap<>(); + item.put("value", tagType.getTagValue()); + item.put("name", tagType.getName()); + item.put("owner", tagType.getOwner()); + item.put("mine", mine); + item.put("shared", shared); + return item; + } + + /** A tag type helper whose visible tags are fixed by the test and whose sharing role is Rguest. */ + private static class StubTagTypeHelper extends TagTypeHelper { + final Map visible = new LinkedHashMap<>(); + final List> requests = new java.util.ArrayList<>(); + + @Override + public Map getVisibleTagTypes(final java.util.Collection values, + final SearchRequestParams.SearchRequestType type) { + org.junit.jupiter.api.Assertions.assertEquals(SearchRequestParams.SearchRequestType.JSON, type); + requests.add(List.copyOf(values)); + final Map map = new LinkedHashMap<>(); + values.forEach(v -> { + if (visible.containsKey(v)) { + map.put(v, visible.get(v)); + } + }); + return map; + } + + @Override + protected List getSharedRoleList() { + return List.of("Rguest"); + } + } + + @Test + public void test_filterDocuments_hitTagsAreVisibleOnlyWithMineAndShared() { + registerQueryFieldConfig(); + final StubTagTypeHelper helper = new StubTagTypeHelper(); + ComponentUtil.register(helper, "tagTypeHelper"); + final TagType own = tagType("foo", "frank", false); + final TagType othersShared = tagType("bar", "bob", true); + final TagType ownShared = tagType("baz", "frank", true); + final Map doc = new LinkedHashMap<>(); + doc.put("title", "Title"); + doc.put("tag", List.of(own.getTagValue(), "hidden", othersShared.getTagValue(), ownShared.getTagValue())); + + final List> out = + new SearchHandler().filterDocuments(List.of(doc), visibleMap(own, othersShared, ownShared), "frank"); + + final Map filtered = out.get(0); + assertEquals("Title", filtered.get("title")); + // the raw values decode to the name and the owner of every tag, visible or not + assertFalse(filtered.containsKey("tag"), filtered.toString()); + assertEquals(List.of(tagItem(own, true, false), tagItem(othersShared, false, true), tagItem(ownShared, true, true)), + filtered.get("tags")); + } + + @Test + public void test_filterDocuments_noVisibleTagMeansNoTagsKey() { + registerQueryFieldConfig(); + final Map tagged = new LinkedHashMap<>(); + tagged.put("title", "tagged"); + tagged.put("tag", new String[] { "hidden" }); + final Map untagged = new LinkedHashMap<>(); + untagged.put("title", "untagged"); + for (final Map filtered : new SearchHandler().filterDocuments(List.of(tagged, untagged), Map.of(), null)) { + assertFalse(filtered.containsKey("tag"), filtered.toString()); + assertFalse(filtered.containsKey("tags"), filtered.toString()); + } + } + + @Test + public void test_buildFacetField_tagBucketsAreVisibleOnesWithOwnerMineShared() { + final StubTagTypeHelper helper = new StubTagTypeHelper(); + ComponentUtil.register(helper, "tagTypeHelper"); + final TagType own = tagType("foo", "frank", false); + final TagType othersShared = tagType("foo", "bob", true); + final TestFacetField tagField = new TestFacetField("tag"); + tagField.overrideName = "tag"; + tagField.overrideValueCountMap.put("hidden", 9L); + tagField.overrideValueCountMap.put(own.getTagValue(), 3L); + tagField.overrideValueCountMap.put(othersShared.getTagValue(), 1L); + final TestFacetField labelField = new TestFacetField("label"); + labelField.overrideName = "label"; + labelField.overrideValueCountMap.put("news", 4L); + final TestFacetResponse fr = new TestFacetResponse(); + fr.addField(tagField); + fr.addField(labelField); + + final List> out = new SearchHandler().buildFacetField(fr, visibleMap(own, othersShared), "frank"); + + final Map ownBucket = new LinkedHashMap<>(); + ownBucket.put("value", own.getTagValue()); + ownBucket.put("count", 3L); + ownBucket.put("label", "foo"); + ownBucket.put("owner", "frank"); + ownBucket.put("mine", true); + ownBucket.put("shared", false); + final Map sharedBucket = new LinkedHashMap<>(); + sharedBucket.put("value", othersShared.getTagValue()); + sharedBucket.put("count", 1L); + sharedBucket.put("label", "foo"); + sharedBucket.put("owner", "bob"); + sharedBucket.put("mine", false); + sharedBucket.put("shared", true); + assertEquals(List.of(ownBucket, sharedBucket), out.get(0).get("result")); + // other facets are untouched + assertEquals(List.of(Map.of("value", "news", "count", 4L)), out.get(1).get("result")); + // the one-argument form has no visible tag + assertEquals(List.of(), new SearchHandler().buildFacetField(fr).get(0).get("result")); + } + + @SafeVarargs + private static void registerTaggedSearch(final List responseFields, final Map... docs) { + ComponentUtil.register(new SearchHelper() { + @Override + public void search(final SearchRequestParams searchRequestParams, final SearchRenderData data, + final OptionalThing userBean) { + responseFields.add(searchRequestParams.getResponseFields()); + data.setDocumentItems(List.of(docs)); + } + }, "searchHelper"); + ComponentUtil.register(new RelatedQueryHelper() { + @Override + public String[] getRelatedQueries(final String query) { + return new String[0]; + } + }, "relatedQueryHelper"); + ComponentUtil.register(new RelatedContentHelper() { + @Override + public String[] getRelatedContents(final String query) { + return new String[0]; + } + }, "relatedContentHelper"); + } + + @SuppressWarnings("unchecked") + @Test + public void test_search_hitTagsAreResolvedOncePerRequest() throws Exception { + registerQueryFieldConfig(); + setUserTagEnabled(true); + final StubTagTypeHelper helper = new StubTagTypeHelper(); + final TagType own = tagType("foo", "frank", false); + final TagType othersShared = tagType("bar", "bob", true); + helper.visible.putAll(visibleMap(own, othersShared)); + ComponentUtil.register(helper, "tagTypeHelper"); + final List responseFields = new java.util.ArrayList<>(); + final Map doc1 = new LinkedHashMap<>(); + doc1.put("title", "one"); + doc1.put("tag", List.of(own.getTagValue(), "hidden")); + final Map doc2 = new LinkedHashMap<>(); + doc2.put("title", "two"); + doc2.put("tag", othersShared.getTagValue()); + registerTaggedSearch(responseFields, doc1, doc2); + + final CapturingResponse res = new CapturingResponse(); + final Map params = new HashMap<>(); + params.put("q", new String[] { "*" }); + new SearchHandler() { + @Override + protected OptionalThing getSavedUserBean() { + return OptionalThing.of(new FessUserBean(new StubFessUser(FessUser.PermissionState.RESOLVED))); + } + }.handle(new StubRequest("/api/v2/search", params), res); + + final String body = res.body(); + assertEquals(body, 200, res.status); + assertTrue(List.of(responseFields.get(0)).contains("tag"), "the v2 search fetches the tag field"); + assertEquals(1, helper.requests.size()); + assertEquals(List.of(own.getTagValue(), "hidden", othersShared.getTagValue()), helper.requests.get(0)); + final Map root = tools.jackson.databind.json.JsonMapper.builder().build().readValue(body, Map.class); + final List> data = (List>) ((Map) root.get("response")).get("data"); + assertEquals(List.of(tagItem(own, true, false)), data.get(0).get("tags")); + assertEquals(List.of(tagItem(othersShared, false, true)), data.get(1).get("tags")); + assertFalse(body.contains("\"tag\":"), body); + assertFalse(body.contains("hidden"), body); + } + + @Test + public void test_search_anonymousGetsNoTags() throws Exception { + registerQueryFieldConfig(); + setUserTagEnabled(true); + final StubTagTypeHelper helper = new StubTagTypeHelper(); + final TagType shared = tagType("bar", "bob", true); + helper.visible.putAll(visibleMap(shared)); + ComponentUtil.register(helper, "tagTypeHelper"); + final Map doc = new LinkedHashMap<>(); + doc.put("title", "one"); + doc.put("tag", List.of(shared.getTagValue())); + registerTaggedSearch(new java.util.ArrayList<>(), doc); + + final CapturingResponse res = new CapturingResponse(); + final Map params = new HashMap<>(); + params.put("q", new String[] { "*" }); + new SearchHandler() { + @Override + protected OptionalThing getSavedUserBean() { + return OptionalThing.empty(); + } + }.handle(new StubRequest("/api/v2/search", params), res); + + final String body = res.body(); + assertEquals(body, 200, res.status); + assertTrue(helper.requests.isEmpty(), "the tags are not looked up for an anonymous caller"); + assertFalse(body.contains("\"tags\""), body); + assertFalse(body.contains("\"tag\""), body); + } + + @Test + public void test_search_responseFieldsIncludeTagOnlyWhenEnabled() throws Exception { + registerQueryFieldConfig(); + for (final boolean enabled : new boolean[] { false, true }) { + setUserTagEnabled(enabled); + ComponentUtil.register(new StubTagTypeHelper(), "tagTypeHelper"); + final List responseFields = new java.util.ArrayList<>(); + registerTaggedSearch(responseFields); + final Map params = new HashMap<>(); + params.put("q", new String[] { "*" }); + new SearchHandler().handle(new StubRequest("/api/v2/search", params), new CapturingResponse()); + org.junit.jupiter.api.Assertions.assertEquals(enabled, List.of(responseFields.get(0)).contains("tag"), "enabled=" + enabled); + // the global response fields are not changed + assertFalse(List.of(ComponentUtil.getQueryFieldConfig().getResponseFields()).contains("tag")); + } + } + private static void registerSearchStubs() { ComponentUtil.register(new SearchHelper() { @Override diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java b/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java new file mode 100644 index 0000000000..793cb35397 --- /dev/null +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java @@ -0,0 +1,519 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import java.io.ByteArrayInputStream; +import java.io.PrintWriter; +import java.io.StringWriter; +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Proxy; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.entity.FessUser; +import org.codelibs.fess.entity.SearchRequestParams.SearchRequestType; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.helper.SystemHelper; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.helper.VirtualHostHelper; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; +import org.dbflute.optional.OptionalEntity; +import org.dbflute.optional.OptionalThing; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import tools.jackson.databind.json.JsonMapper; + +/** + * In-memory tag store, tag helper, configuration and servlet stubs shared by {@link TagsHandlerTest} and + * {@link DocumentTagsHandlerTest}. + * + *

    {@link FakeTagTypeService} keeps the tag types in memory with the same contract as the real service: listings + * leave out the paths, an insert of an existing id and an update with a stale sequence number throw + * {@link TagTypeConflictException}. {@link FakeTagTypeHelper} is the real helper (value encoding, name rules, + * permissions, visibility) reading that store, with the change queue recorded.

    + */ +final class TagHandlerTestSupport { + + private TagHandlerTestSupport() { + // utility + } + + /** The test environment: configuration, store, helper and caller. */ + static class Env { + final TagFessConfig fessConfig = new TagFessConfig(); + final FakeTagTypeService service = new FakeTagTypeService(); + final FakeTagTypeHelper helper = new FakeTagTypeHelper(this); + /** The roles of the caller besides its own user role and the sharing role. */ + final Set roles = new LinkedHashSet<>(); + String virtualHostKey = ""; + OptionalThing user = OptionalThing.empty(); + + Env() { + ComponentUtil.setFessConfig(fessConfig); + ComponentUtil.register(new SystemHelper(), "systemHelper"); + ComponentUtil.register(new VirtualHostHelper() { + @Override + public String getVirtualHostKey() { + return virtualHostKey; + } + }, "virtualHostHelper"); + ComponentUtil.register(helper, "tagTypeHelper"); + } + + Env user(final String name) { + user = name == null ? OptionalThing.empty() : OptionalThing.of(new FessUserBean(new StubFessUser(name))); + return this; + } + + /** Stores a tag and returns it. */ + TagType put(final String name, final String owner, final boolean shared, final String... paths) { + final TagType tagType = new TagType(); + tagType.setName(name); + tagType.setOwner(owner); + tagType.setId(helper.toId(tagType.getTagValue())); + tagType.setPaths(paths); + tagType.setPermissions(shared ? new String[] { "1" + owner, "Rguest" } : new String[] { "1" + owner }); + tagType.setVirtualHost(""); + tagType.setSortOrder(0); + service.store.put(tagType.getId(), copy(tagType, true)); + service.seqNoMap.put(tagType.getId(), 0L); + return tagType; + } + + TagType stored(final String name, final String owner) { + return service.store.get(helper.toId(TagType.toTagValue(name, owner))); + } + } + + static TagType copy(final TagType src, final boolean withPaths) { + final TagType dest = new TagType(); + dest.setId(src.getId()); + dest.setName(src.getName()); + dest.setOwner(src.getOwner()); + if (withPaths && src.getPaths() != null) { + dest.setPaths(src.getPaths().clone()); + } + dest.setPermissions(src.getPermissions() == null ? null : src.getPermissions().clone()); + dest.setVirtualHost(src.getVirtualHost()); + dest.setSortOrder(src.getSortOrder()); + dest.setCreatedBy(src.getCreatedBy()); + dest.setCreatedTime(src.getCreatedTime()); + dest.setUpdatedBy(src.getUpdatedBy()); + dest.setUpdatedTime(src.getUpdatedTime()); + return dest; + } + + /** Keeps the tag types in memory with the contract of {@link TagTypeService}. */ + static class FakeTagTypeService extends TagTypeService { + final Map store = new LinkedHashMap<>(); + final Map seqNoMap = new HashMap<>(); + final List calls = new ArrayList<>(); + /** The number of the next updates that lose a race with another writer. */ + int updateConflicts; + + @Override + public OptionalEntity getTagType(final String id) { + final TagType stored = store.get(id); + if (stored == null) { + return OptionalEntity.empty(); + } + final TagType tagType = copy(stored, true); + tagType.setSeqNo(seqNoMap.get(id)); + tagType.setPrimaryTerm(1L); + return OptionalEntity.of(tagType); + } + + @Override + public List getTagTypeListByOwner(final String owner) { + return store.values() + .stream() + .filter(t -> owner.equals(t.getOwner())) + .sorted(Comparator.comparing(TagType::getSortOrder).thenComparing(TagType::getName)) + .map(t -> copy(t, false)) + .toList(); + } + + @Override + public long countByOwner(final String owner) { + return store.values().stream().filter(t -> owner.equals(t.getOwner())).count(); + } + + @Override + public Map getPathCountMapByOwner(final String owner) { + final Map map = new LinkedHashMap<>(); + store.values() + .stream() + .filter(t -> owner.equals(t.getOwner())) + .forEach(t -> map.put(t.getName(), t.getPaths() == null ? 0L : (long) t.getPaths().length)); + return map; + } + + @Override + public void insert(final TagType tagType) { + calls.add("insert " + tagType.getName() + "/" + tagType.getOwner()); + if (tagType.getId() == null) { + throw new IllegalArgumentException("no id"); + } + if (store.containsKey(tagType.getId())) { + throw new TagTypeConflictException("exists: " + tagType.getId(), null); + } + store.put(tagType.getId(), copy(tagType, true)); + seqNoMap.put(tagType.getId(), 0L); + } + + @Override + public void update(final TagType tagType) { + calls.add("update " + tagType.getName() + "/" + tagType.getOwner()); + final String id = tagType.getId(); + if (tagType.getSeqNo() == null || tagType.getPrimaryTerm() == null) { + throw new IllegalArgumentException("no seqNo"); + } + if (updateConflicts > 0) { + updateConflicts--; + seqNoMap.merge(id, 1L, Long::sum); + throw new TagTypeConflictException("changed: " + id, null); + } + if (!store.containsKey(id) || !tagType.getSeqNo().equals(seqNoMap.get(id))) { + throw new TagTypeConflictException("changed: " + id, null); + } + store.put(id, copy(tagType, true)); + seqNoMap.merge(id, 1L, Long::sum); + } + + @Override + public void delete(final TagType tagType) { + calls.add("delete " + tagType.getName() + "/" + tagType.getOwner()); + store.remove(tagType.getId()); + } + } + + /** The real helper reading {@link FakeTagTypeService}, with the change queue recorded. */ + static class FakeTagTypeHelper extends TagTypeHelper { + private final Env env; + final List changes = new ArrayList<>(); + + FakeTagTypeHelper(final Env env) { + this.env = env; + } + + @Override + public boolean enqueue(final TagChange change) { + changes.add(change); + return true; + } + + @Override + protected Map fetchTagTypes(final Collection values) { + final Map map = new LinkedHashMap<>(); + for (final String value : values) { + final TagType stored = env.service.store.get(toId(value)); + if (stored != null) { + map.put(value, copy(stored, false)); + } + } + return map; + } + + @Override + public Map> findTagValuesByUrls(final Collection urls) { + final Map> map = new HashMap<>(); + for (final TagType tagType : env.service.store.values()) { + if (tagType.getPaths() != null) { + for (final String path : tagType.getPaths()) { + if (urls.contains(path)) { + map.computeIfAbsent(path, k -> new LinkedHashSet<>()).add(tagType.getTagValue()); + } + } + } + } + return map; + } + + @Override + protected Set getViewerRoles(final SearchRequestType type) { + final Set roles = new LinkedHashSet<>(env.roles); + env.user.ifPresent(u -> roles.add("1" + u.getUserId())); + roles.add("Rguest"); + return roles; + } + + @Override + protected List getSharedRoleList() { + return List.of("Rguest"); + } + + @Override + protected OptionalThing getSavedUserBean() { + return env.user; + } + } + + /** A configuration whose user tag settings are set by the test. */ + static class TagFessConfig extends FessConfig.SimpleImpl { + private static final long serialVersionUID = 1L; + boolean enabled = true; + int nameMaxLength = 50; + int maxTags = 1000; + int maxPaths = 10000; + + @Override + public boolean isUserTagEnabled() { + return enabled; + } + + @Override + public Integer getUserTagNameMaxLengthAsInteger() { + return nameMaxLength; + } + + @Override + public Integer getUserTagMaxTagsAsInteger() { + return maxTags; + } + + @Override + public Integer getUserTagMaxPathsAsInteger() { + return maxPaths; + } + + @Override + public String getRoleSearchUserPrefix() { + return "1"; + } + + @Override + public List getSearchGuestRoleList() { + return List.of("Rguest", "1guest"); + } + + @Override + public String getIndexFieldUrl() { + return "url"; + } + + @Override + public String getIndexFieldTag() { + return "tag"; + } + } + + /** Minimal {@link FessUser} with a name. */ + static class StubFessUser implements FessUser { + private static final long serialVersionUID = 1L; + private final String name; + + StubFessUser(final String name) { + this.name = name; + } + + @Override + public String getName() { + return name; + } + + @Override + public String[] getRoleNames() { + return new String[0]; + } + + @Override + public String[] getGroupNames() { + return new String[0]; + } + + @Override + public String[] getPermissions() { + return new String[0]; + } + } + + /** A request built through a dynamic proxy: method, headers and an optional JSON body. */ + static class StubRequest { + private final String method; + private final Map headers = new HashMap<>(); + private byte[] body = new byte[0]; + private String contentType; + + StubRequest(final String method) { + this.method = method; + } + + StubRequest header(final String name, final String value) { + headers.put(name, value); + return this; + } + + StubRequest json(final String json) { + contentType = "application/json"; + body = json.getBytes(StandardCharsets.UTF_8); + return this; + } + + HttpServletRequest proxy() { + final ByteArrayInputStream in = new ByteArrayInputStream(body); + final ServletInputStream stream = new ServletInputStream() { + @Override + public int read() { + return in.read(); + } + + @Override + public boolean isFinished() { + return in.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(final ReadListener listener) { + // not used + } + }; + final InvocationHandler h = (proxy, m, args) -> switch (m.getName()) { + case "getMethod" -> method; + case "getHeader" -> headers.get(args[0]); + case "getContentType" -> contentType; + case "getContentLength" -> body.length; + case "getContentLengthLong" -> (long) body.length; + case "getInputStream" -> stream; + case "getLocale" -> java.util.Locale.ENGLISH; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "StubRequest[" + method + "]"; + default -> defaultValue(m.getReturnType()); + }; + return (HttpServletRequest) Proxy.newProxyInstance(TagHandlerTestSupport.class.getClassLoader(), + new Class[] { HttpServletRequest.class }, h); + } + } + + /** Captures the status, the headers and the body written through a dynamic proxy. */ + static class Response { + int status = 200; + final Map headers = new HashMap<>(); + private final StringWriter sw = new StringWriter(); + private final PrintWriter writer = new PrintWriter(sw); + + String body() { + writer.flush(); + return sw.toString(); + } + + HttpServletResponse proxy() { + final InvocationHandler h = (proxy, m, args) -> switch (m.getName()) { + case "setStatus" -> { + status = (Integer) args[0]; + yield null; + } + case "getStatus" -> status; + case "setHeader", "addHeader" -> { + headers.put((String) args[0], (String) args[1]); + yield null; + } + case "getHeader" -> headers.get(args[0]); + case "getWriter" -> writer; + case "resetBuffer" -> { + writer.flush(); + sw.getBuffer().setLength(0); + yield null; + } + case "getCharacterEncoding" -> "UTF-8"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + case "toString" -> "Response[" + status + "]"; + default -> defaultValue(m.getReturnType()); + }; + return (HttpServletResponse) Proxy.newProxyInstance(TagHandlerTestSupport.class.getClassLoader(), + new Class[] { HttpServletResponse.class }, h); + } + + @SuppressWarnings("unchecked") + Map envelope() { + final Map root = JsonMapper.builder().build().readValue(body(), Map.class); + return (Map) root.get("response"); + } + + Map payload() { + final Map envelope = envelope(); + if (!Integer.valueOf(0).equals(envelope.get("status"))) { + throw new AssertionError("not a success: " + body()); + } + return envelope; + } + + @SuppressWarnings("unchecked") + String errorCode() { + return (String) ((Map) envelope().get("error")).get("code"); + } + + @SuppressWarnings("unchecked") + String errorMessage() { + return (String) ((Map) envelope().get("error")).get("message"); + } + } + + /** The JSON of a tag on a document or in a search hit list. */ + static Map docTag(final TagType tagType, final boolean mine, final boolean shared) { + final Map map = new LinkedHashMap<>(); + map.put("id", tagType.getId()); + map.put("value", tagType.getTagValue()); + map.put("name", tagType.getName()); + map.put("owner", tagType.getOwner()); + map.put("mine", mine); + map.put("shared", shared); + return map; + } + + static List list(final String... values) { + return Arrays.asList(values); + } + + private static Object defaultValue(final Class type) { + if (!type.isPrimitive()) { + return null; + } + if (type == boolean.class) { + return Boolean.FALSE; + } + if (type == int.class) { + return 0; + } + if (type == long.class) { + return 0L; + } + return null; + } +} diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java new file mode 100644 index 0000000000..a86d44c513 --- /dev/null +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java @@ -0,0 +1,373 @@ +/* + * Copyright 2012-2025 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.api.v2.handlers; + +import static org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.list; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.Env; +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.Response; +import org.codelibs.fess.api.v2.handlers.TagHandlerTestSupport.StubRequest; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.mylasta.action.FessUserBean; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.unit.UnitFessTestCase; +import org.dbflute.optional.OptionalThing; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInfo; + +/** + * Unit tests for {@link TagsHandler}: {@code GET/POST /api/v2/tags} and {@code PUT/DELETE /api/v2/tags/{id}}. + */ +public class TagsHandlerTest extends UnitFessTestCase { + + private Env env; + + @Override + protected void setUp(final TestInfo testInfo) throws Exception { + super.setUp(testInfo); + env = new Env(); + } + + private class StubHandler extends TagsHandler { + @Override + protected OptionalThing getUserBean() { + return env.user; + } + + @Override + protected TagTypeService getTagTypeService() { + return env.service; + } + } + + private Response call(final StubRequest req, final String id) throws IOException { + final Response res = new Response(); + new StubHandler().handle(req.proxy(), res.proxy(), id); + return res; + } + + private static StubRequest request(final String method) { + return new StubRequest(method); + } + + private static Map listItem(final TagType tagType, final boolean shared, final long pathCount) { + final Map map = new LinkedHashMap<>(); + map.put("id", tagType.getId()); + map.put("value", tagType.getTagValue()); + map.put("name", tagType.getName()); + map.put("shared", shared); + map.put("sort_order", 0); + map.put("path_count", (int) pathCount); + return map; + } + + // =================================================================================== + // Request gates + // ============= + + @Test + public void test_disabled_returnsInvalidRequestForEveryMethod() throws Exception { + env.fessConfig.enabled = false; + env.user("alice"); + final TagType tag = env.put("foo", "alice", false); + final String[][] requests = { { "GET", null }, { "POST", null }, { "PUT", tag.getId() }, { "DELETE", tag.getId() } }; + for (final String[] r : requests) { + final Response res = call(request(r[0]).json("{\"name\":\"bar\"}"), r[1]); + Assertions.assertEquals(400, res.status, r[0]); + Assertions.assertEquals("invalid_request", res.errorCode(), r[0]); + Assertions.assertEquals("tag feature is not available", res.errorMessage(), r[0]); + } + Assertions.assertEquals(List.of(), env.service.calls); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_anonymous_returnsAuthRequired() throws Exception { + final TagType tag = env.put("foo", "alice", true); + final String[][] requests = { { "GET", null }, { "POST", null }, { "PUT", tag.getId() }, { "DELETE", tag.getId() } }; + for (final String[] r : requests) { + final Response res = call(request(r[0]).json("{\"name\":\"bar\"}"), r[1]); + Assertions.assertEquals(401, res.status, r[0]); + Assertions.assertEquals("auth_required", res.errorCode(), r[0]); + Assertions.assertEquals("login required", res.errorMessage(), r[0]); + } + Assertions.assertEquals(List.of(), env.service.calls); + } + + @Test + public void test_accessTokenWithoutLogin_isRefused() throws Exception { + // An access token gives a request the token's roles, never a user; the tags need a user. + final Response res = call(request("POST").header("Authorization", "Bearer token").json("{\"name\":\"bar\"}"), null); + Assertions.assertEquals(401, res.status); + Assertions.assertEquals("auth_required", res.errorCode()); + Assertions.assertEquals(List.of(), env.service.calls); + } + + @Test + public void test_unsupportedMethods_return405() throws Exception { + env.user("alice"); + for (final String method : new String[] { "PUT", "DELETE", "PATCH" }) { + final Response res = call(request(method), null); + Assertions.assertEquals(405, res.status, method); + Assertions.assertEquals("GET, POST", res.headers.get("Allow"), method); + } + for (final String method : new String[] { "GET", "POST", "PATCH" }) { + final Response res = call(request(method), "0123"); + Assertions.assertEquals(405, res.status, method); + Assertions.assertEquals("PUT, DELETE", res.headers.get("Allow"), method); + } + } + + // =================================================================================== + // List/Create + // =========== + + @Test + public void test_get_listsOwnTagsWithPathCounts() throws Exception { + env.user("alice"); + final TagType foo = env.put("foo", "alice", false, "http://a/", "http://b/"); + final TagType bar = env.put("bar", "alice", true); + env.put("baz", "bob", true, "http://a/"); + final Response res = call(request("GET"), null); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(List.of(listItem(bar, true, 0), listItem(foo, false, 2)), res.payload().get("tags")); + } + + @Test + public void test_post_createsPrivateOrSharedTag() throws Exception { + env.user("alice"); + final Response res = call(request("POST").json("{\"name\":\" FOO \"}"), null); + Assertions.assertEquals(200, res.status, res.body()); + final TagType stored = env.stored("FOO", "alice"); + Assertions.assertNotNull(stored); + Assertions.assertEquals(env.helper.toId(stored.getTagValue()), stored.getId()); + Assertions.assertEquals(list("1alice"), list(stored.getPermissions())); + Assertions.assertEquals(0, stored.getSortOrder()); + // the entity reads an empty virtual host as null + Assertions.assertNull(stored.getVirtualHost()); + Assertions.assertEquals("alice", stored.getCreatedBy()); + Assertions.assertNotNull(stored.getCreatedTime()); + Assertions.assertEquals(listItem(stored, false, 0), res.payload().get("tag")); + + final Response shared = call(request("POST").json("{\"name\":\"bar\",\"shared\":true}"), null); + Assertions.assertEquals(200, shared.status, shared.body()); + Assertions.assertEquals(list("1alice", "Rguest"), list(env.stored("bar", "alice").getPermissions())); + // creating a tag puts it on no document + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_post_takesTheVirtualHostOfTheRequest() throws Exception { + env.user("alice"); + env.virtualHostKey = "site1"; + Assertions.assertEquals(200, call(request("POST").json("{\"name\":\"foo\"}"), null).status); + Assertions.assertEquals("site1", env.stored("foo", "alice").getVirtualHost()); + } + + @Test + public void test_post_invalidName_returnsInvalidRequest() throws Exception { + env.user("alice"); + for (final String body : new String[] { "{}", "{\"name\":\" \"}", "{\"name\":1}", "{\"name\":\"" + "a".repeat(51) + "\"}" }) { + final Response res = call(request("POST").json(body), null); + Assertions.assertEquals(400, res.status, body); + Assertions.assertEquals("invalid tag name: enter 1 to 50 characters", res.errorMessage(), body); + } + final Response res = call(request("POST").json("{\"name\":\"foo\",\"shared\":\"yes\"}"), null); + Assertions.assertEquals(400, res.status); + Assertions.assertEquals("shared must be a boolean", res.errorMessage()); + Assertions.assertEquals(List.of(), env.service.calls); + } + + @Test + public void test_post_duplicate_returnsConflict() throws Exception { + env.user("alice"); + env.put("foo", "alice", false, "http://a/"); + final Response res = call(request("POST").json("{\"name\":\"foo\"}"), null); + Assertions.assertEquals(409, res.status, res.body()); + Assertions.assertEquals("conflict", res.errorCode()); + Assertions.assertEquals("a tag with the name already exists", res.errorMessage()); + // the existing tag is untouched + Assertions.assertEquals(list("http://a/"), list(env.stored("foo", "alice").getPaths())); + } + + @Test + public void test_post_sameNameOfAnotherOwner_isAnotherTag() throws Exception { + env.user("alice"); + env.put("foo", "bob", true); + Assertions.assertEquals(200, call(request("POST").json("{\"name\":\"foo\"}"), null).status); + Assertions.assertNotNull(env.stored("foo", "alice")); + } + + @Test + public void test_post_maxTagsReached_returnsInvalidRequest() throws Exception { + env.user("alice"); + env.fessConfig.maxTags = 2; + env.put("a", "alice", false); + env.put("b", "alice", false); + env.put("c", "bob", false); + final Response res = call(request("POST").json("{\"name\":\"d\"}"), null); + Assertions.assertEquals(400, res.status, res.body()); + Assertions.assertEquals("invalid_request", res.errorCode()); + Assertions.assertEquals("too many tags: a user can have up to 2 tags", res.errorMessage()); + Assertions.assertNull(env.stored("d", "alice")); + } + + // =================================================================================== + // Update/Delete + // ============= + + @Test + public void test_put_rename_enqueuesRenameAndReturnsNewId() throws Exception { + env.user("alice"); + final TagType old = env.put("foo", "alice", true, "http://a/", "http://b/"); + final Response res = call(request("PUT").json("{\"name\":\"bar\"}"), old.getId()); + Assertions.assertEquals(200, res.status, res.body()); + final TagType renamed = env.stored("bar", "alice"); + Assertions.assertNotNull(renamed); + Assertions.assertNull(env.service.store.get(old.getId())); + Assertions.assertEquals(list("http://a/", "http://b/"), list(renamed.getPaths())); + Assertions.assertEquals(list("1alice", "Rguest"), list(renamed.getPermissions())); + Assertions.assertEquals(listItem(renamed, true, 2), res.payload().get("tag")); + Assertions.assertEquals(Boolean.TRUE, res.payload().get("renamed")); + Assertions.assertNotEquals(old.getId(), renamed.getId()); + Assertions.assertEquals(List.of(TagChange.rename(old.getTagValue(), renamed.getTagValue())), env.helper.changes); + } + + @Test + public void test_put_renameToExistingName_returnsConflictAndKeepsTheOldTag() throws Exception { + env.user("alice"); + final TagType old = env.put("foo", "alice", false, "http://a/"); + env.put("bar", "alice", false); + final Response res = call(request("PUT").json("{\"name\":\"bar\"}"), old.getId()); + Assertions.assertEquals(409, res.status, res.body()); + Assertions.assertEquals("a tag with the name already exists", res.errorMessage()); + Assertions.assertNotNull(env.service.store.get(old.getId())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_put_shareToggle_changesPermissionsOnly() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + Response res = call(request("PUT").json("{\"shared\":true}"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + TagType stored = env.service.store.get(tag.getId()); + Assertions.assertEquals(list("1alice", "Rguest"), list(stored.getPermissions())); + Assertions.assertEquals(list("http://a/"), list(stored.getPaths())); + Assertions.assertEquals(Boolean.FALSE, res.payload().get("renamed")); + Assertions.assertEquals(listItem(stored, true, 1), res.payload().get("tag")); + + res = call(request("PUT").json("{\"name\":\"foo\",\"shared\":false}"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + stored = env.service.store.get(tag.getId()); + Assertions.assertEquals(list("1alice"), list(stored.getPermissions())); + Assertions.assertEquals(list("http://a/"), list(stored.getPaths())); + // sharing changes who sees the tag, not which documents carry it + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_put_shareToggle_retriesAConcurrentUpdate() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + env.service.updateConflicts = 2; + final Response res = call(request("PUT").json("{\"shared\":true}"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(list("1alice", "Rguest"), list(env.service.store.get(tag.getId()).getPermissions())); + + env.service.updateConflicts = 10; + final Response failed = call(request("PUT").json("{\"shared\":false}"), tag.getId()); + Assertions.assertEquals(409, failed.status, failed.body()); + Assertions.assertEquals("conflict", failed.errorCode()); + } + + @Test + public void test_put_sameNameOnly_changesNothing() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", true, "http://a/"); + final Response res = call(request("PUT").json("{\"name\":\"foo\"}"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(Boolean.FALSE, res.payload().get("renamed")); + Assertions.assertEquals(list("1alice", "Rguest"), list(env.service.store.get(tag.getId()).getPermissions())); + Assertions.assertEquals(List.of(), env.service.calls); + } + + @Test + public void test_put_shareToggle_keepsOtherPermissions() throws Exception { + // an administrator may have given the tag to a group as well + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + env.service.store.get(tag.getId()).setPermissions(new String[] { "1alice", "2staff" }); + Assertions.assertEquals(200, call(request("PUT").json("{\"shared\":true}"), tag.getId()).status); + Assertions.assertEquals(list("1alice", "2staff", "Rguest"), list(env.service.store.get(tag.getId()).getPermissions())); + Assertions.assertEquals(200, call(request("PUT").json("{\"shared\":false}"), tag.getId()).status); + Assertions.assertEquals(list("1alice", "2staff"), list(env.service.store.get(tag.getId()).getPermissions())); + } + + @Test + public void test_put_withoutChanges_returnsInvalidRequest() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false); + final Response res = call(request("PUT").json("{}"), tag.getId()); + Assertions.assertEquals(400, res.status); + Assertions.assertEquals("name or shared is required", res.errorMessage()); + } + + @Test + public void test_delete_removesTheTagAndEnqueuesDelete() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + final Response res = call(request("DELETE"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertEquals(tag.getId(), res.payload().get("id")); + Assertions.assertEquals(Boolean.TRUE, res.payload().get("deleted")); + Assertions.assertNull(env.service.store.get(tag.getId())); + Assertions.assertEquals(List.of(TagChange.delete(tag.getTagValue())), env.helper.changes); + } + + @Test + public void test_deleteOthersTag_returnsForbidden() throws Exception { + env.user("alice"); + final TagType shared = env.put("foo", "bob", true, "http://a/"); + for (final String method : new String[] { "DELETE", "PUT" }) { + final Response res = call(request(method).json("{\"name\":\"x\"}"), shared.getId()); + Assertions.assertEquals(403, res.status, method + " " + res.body()); + Assertions.assertEquals("forbidden", res.errorCode()); + } + Assertions.assertNotNull(env.service.store.get(shared.getId())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_invisibleOrUnknownTag_returnsNotFound() throws Exception { + env.user("alice"); + final TagType hidden = env.put("foo", "bob", false); + for (final String id : new String[] { hidden.getId(), "0".repeat(64), "not-an-id" }) { + final Response res = call(request("DELETE"), id); + Assertions.assertEquals(404, res.status, id); + Assertions.assertEquals("not_found", res.errorCode()); + Assertions.assertEquals("tag not found", res.errorMessage()); + } + Assertions.assertNotNull(env.service.store.get(hidden.getId())); + } +} diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java index dd01ecc970..d6bb10e8ef 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/UiConfigHandlerTest.java @@ -598,6 +598,36 @@ public void test_features_searchHistory_offWhenDisabled() throws Exception { assertTrue(res.body().contains("\"search_history\":false"), res.body()); } + @Test + public void test_features_userTag_offByDefault() throws Exception { + final CapturingResponse res = new CapturingResponse(); + new UiConfigHandler().handle(new StubRequest("GET", "/api/v2/ui/config").withSession(new StubSession()), res); + assertEquals(200, res.status, res.body()); + assertTrue(res.body().contains("\"user_tag\":false"), res.body()); + } + + @Test + public void test_features_userTag_onWhenEnabled() throws Exception { + // Delegate every call to the real configuration except user.tag.enabled. + final org.codelibs.fess.mylasta.direction.FessConfig real = ComponentUtil.getFessConfig(); + ComponentUtil.setFessConfig((org.codelibs.fess.mylasta.direction.FessConfig) java.lang.reflect.Proxy.newProxyInstance( + org.codelibs.fess.mylasta.direction.FessConfig.class.getClassLoader(), + new Class[] { org.codelibs.fess.mylasta.direction.FessConfig.class }, (proxy, method, args) -> { + if ("isUserTagEnabled".equals(method.getName())) { + return true; + } + try { + return method.invoke(real, args); + } catch (final java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + })); + final CapturingResponse res = new CapturingResponse(); + new UiConfigHandler().handle(new StubRequest("GET", "/api/v2/ui/config").withSession(new StubSession()), res); + assertEquals(200, res.status, res.body()); + assertTrue(res.body().contains("\"user_tag\":true"), res.body()); + } + @Test public void test_features_searchExport_offByDefault() throws Exception { final CapturingResponse res = new CapturingResponse(); diff --git a/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java index fe6f18a152..9d69a58015 100644 --- a/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java +++ b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java @@ -177,6 +177,40 @@ public void test_getTagValue_encodesNameAndOwner() { assertNull(tagType.getTagValue()); } + @Test + public void test_getPathCountMapByOwner_aggregatesWithoutReadingPaths() { + final String[] dsl = new String[1]; + final TagTypeBhv bhv = new TagTypeBhv() { + @Override + public org.dbflute.cbean.result.PagingResultBean selectPage( + final org.dbflute.bhv.readable.CBCall cbLambda) { + final org.codelibs.fess.opensearch.config.cbean.TagTypeCB cb = new org.codelibs.fess.opensearch.config.cbean.TagTypeCB(); + cbLambda.callback(cb); + final org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder builder = + new org.codelibs.fesen.opensearch.action.search.SearchRequestBuilder(null, + org.codelibs.fesen.opensearch.action.search.SearchAction.INSTANCE); + cb.build(builder); + dsl[0] = builder.toString().replaceAll("\\s+", ""); + return new org.codelibs.fess.opensearch.config.allcommon.EsPagingResultBean<>(builder); + } + }; + final TagTypeService service = createService(bhv); + service.fessConfig = new org.codelibs.fess.mylasta.direction.FessConfig.SimpleImpl() { + private static final long serialVersionUID = 1L; + + @Override + public Integer getUserTagMaxTagsAsInteger() { + return 7; + } + }; + + // no aggregations in the response: no counts + assertTrue(service.getPathCountMapByOwner("alice").isEmpty()); + assertTrue(dsl[0].contains("\"owner\":{\"value\":\"alice\""), dsl[0]); + assertTrue(dsl[0].contains("\"terms\":{\"field\":\"name\",\"size\":7"), dsl[0]); + assertTrue(dsl[0].contains("\"value_count\":{\"field\":\"paths\"}"), dsl[0]); + } + /** * Stand-in for {@code org.codelibs.fesen.opensearch.index.engine.VersionConflictEngineException}, * recognized by its class name. From c683901e8aeee275c96a21faf48f5091ec5fbc69 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 20:32:44 +0900 Subject: [PATCH 10/14] fix(api): delete tags with a version check A tag was deleted by id alone, so a URL added to it between the read and the delete was lost, and a rename left the documents with a value whose tag no longer had that URL. - TagTypeService.delete now sends the sequence number and primary term the tag was read with and reports a lost race as TagTypeConflictException, like update. - DELETE /api/v2/tags/{id} retries on a fresh read and answers 409 when it keeps losing; the deletion is queued only after the delete succeeded. - A rename that cannot delete the old tag removes the tag it created and, on a lost race, retries with the old tag read again, carrying over the URLs added meanwhile; it answers 409 when it keeps losing. The rename is queued only after the old tag is gone. --- .../fess/api/v2/handlers/TagsHandler.java | 97 ++++++++++++++----- .../fess/app/service/TagTypeService.java | 28 +++++- .../v2/handlers/TagHandlerTestSupport.java | 30 +++++- .../fess/api/v2/handlers/TagsHandlerTest.java | 64 ++++++++++++ .../fess/app/service/TagTypeServiceTest.java | 51 ++++++++++ 5 files changed, 239 insertions(+), 31 deletions(-) diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java index 4b6b127215..52766f3f91 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java @@ -155,32 +155,68 @@ private Map updateTag(final HttpServletRequest req, final String /** * Renames a tag: the tag of the new name is created with the paths, permissions, virtual host and sort order of * the old one, the old one is deleted, and the rename of the value on the documents is queued. When the caller - * has a tag of the new name already, nothing changes. + * has a tag of the new name already, nothing changes. The old tag is deleted only if it did not change since it + * was read; otherwise the new tag is removed again and the rename is retried on a fresh read, so that a URL + * added to the old tag meanwhile is carried over. */ - private TagType renameTag(final TagType current, final String name, final Boolean shared, final String userId) - throws TagRequestException { + private TagType renameTag(final TagType read, final String name, final Boolean shared, final String userId) throws TagRequestException { final TagTypeHelper helper = getTagTypeHelper(); - final TagType renamed = new TagType(); - renamed.setName(name); - renamed.setOwner(current.getOwner()); - renamed.setId(helper.toId(renamed.getTagValue())); - renamed.setPaths(current.getPaths()); - renamed.setPermissions( - shared == null ? current.getPermissions() : applyShared(current.getPermissions(), current.getOwner(), shared)); - renamed.setVirtualHost(current.getVirtualHost()); - renamed.setSortOrder(current.getSortOrder()); - renamed.setCreatedBy(current.getCreatedBy()); - renamed.setCreatedTime(current.getCreatedTime()); - renamed.setUpdatedBy(userId); - renamed.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); + TagType current = read; + for (int attempt = 1;; attempt++) { + if (attempt > 1) { + current = getOwnTagType(read.getId(), userId); + } + final TagType renamed = new TagType(); + renamed.setName(name); + renamed.setOwner(current.getOwner()); + renamed.setId(helper.toId(renamed.getTagValue())); + renamed.setPaths(current.getPaths()); + renamed.setPermissions( + shared == null ? current.getPermissions() : applyShared(current.getPermissions(), current.getOwner(), shared)); + renamed.setVirtualHost(current.getVirtualHost()); + renamed.setSortOrder(current.getSortOrder()); + renamed.setCreatedBy(current.getCreatedBy()); + renamed.setCreatedTime(current.getCreatedTime()); + renamed.setUpdatedBy(userId); + renamed.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); + try { + getTagTypeService().insert(renamed); + } catch (final TagTypeConflictException e) { + throw new TagRequestException(V2ErrorCode.CONFLICT, "a tag with the name already exists"); + } + try { + getTagTypeService().delete(current); + } catch (final TagTypeConflictException e) { + rollBack(renamed); + if (attempt >= MAX_UPDATE_ATTEMPTS) { + logger.warn("Failed to rename the tag after {} attempts: id={}", attempt, read.getId(), e); + throw new TagRequestException(V2ErrorCode.CONFLICT, "the tag was changed concurrently; try again"); + } + if (logger.isDebugEnabled()) { + logger.debug("The tag was changed concurrently; retrying the rename: id={}, attempt={}", read.getId(), attempt); + } + continue; + } catch (final RuntimeException e) { + rollBack(renamed); + throw e; + } + enqueue(TagChange.rename(current.getTagValue(), renamed.getTagValue())); + return renamed; + } + } + + /** + * Deletes the tag created for a rename that could not delete the old tag, so that the caller is not left with + * both. A failure is logged; the caller can delete the leftover tag. + * + * @param inserted the tag created for the rename + */ + private void rollBack(final TagType inserted) { try { - getTagTypeService().insert(renamed); - } catch (final TagTypeConflictException e) { - throw new TagRequestException(V2ErrorCode.CONFLICT, "a tag with the name already exists"); + getTagTypeService().getTagType(inserted.getId()).ifPresent(getTagTypeService()::delete); + } catch (final RuntimeException e) { + logger.warn("Failed to remove the tag created for a rename: id={}", inserted.getId(), e); } - getTagTypeService().delete(current); - enqueue(TagChange.rename(current.getTagValue(), renamed.getTagValue())); - return renamed; } /** @@ -217,9 +253,20 @@ private String[] applyShared(final String[] permissions, final String owner, fin } private Map deleteTag(final String tagId, final String userId) throws TagRequestException { - final TagType tagType = getOwnTagType(tagId, userId); - getTagTypeService().delete(tagType); - enqueue(TagChange.delete(tagType.getTagValue())); + for (int attempt = 1;; attempt++) { + final TagType tagType = getOwnTagType(tagId, userId); + try { + getTagTypeService().delete(tagType); + } catch (final TagTypeConflictException e) { + if (attempt >= MAX_UPDATE_ATTEMPTS) { + logger.warn("Failed to delete the tag after {} attempts: id={}", attempt, tagId, e); + throw new TagRequestException(V2ErrorCode.CONFLICT, "the tag was changed concurrently; try again"); + } + continue; + } + enqueue(TagChange.delete(tagType.getTagValue())); + break; + } final Map payload = new LinkedHashMap<>(); payload.put("id", tagId); payload.put("deleted", true); diff --git a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java index f32bff5aa3..42648904d0 100644 --- a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java +++ b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java @@ -224,14 +224,32 @@ public void update(final TagType tagType) { } /** - * Delete a tag type. + * Delete a tag type that was read with {@link #getTagType(String)}. The delete fails when the + * stored tag type changed since it was read, so that a concurrent change, such as a URL added to + * its paths, is not dropped unseen. * - * @param tagType The tag type to delete. + * @param tagType The tag type to delete, carrying the sequence number and primary term it was read with. + * @throws IllegalArgumentException When the tag type carries no sequence number or primary term. + * @throws TagTypeConflictException When the stored tag type changed since it was read. */ public void delete(final TagType tagType) { - tagTypeBhv.delete(tagType, op -> { - op.setRefreshPolicy(Constants.TRUE); - }); + final Long seqNo = tagType.getSeqNo(); + final Long primaryTerm = tagType.getPrimaryTerm(); + if (seqNo == null || seqNo.longValue() < 0L || primaryTerm == null || primaryTerm.longValue() <= 0L) { + throw new IllegalArgumentException("The tag type has no sequence number or primary term: id=" + tagType.getId()); + } + try { + tagTypeBhv.delete(tagType, op -> { + op.setIfSeqNo(seqNo); + op.setIfPrimaryTerm(primaryTerm); + op.setRefreshPolicy(Constants.TRUE); + }); + } catch (final RuntimeException e) { + if (isVersionConflict(e)) { + throw new TagTypeConflictException("The tag type was changed concurrently: id=" + tagType.getId(), e); + } + throw e; + } } /** diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java b/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java index 793cb35397..c1f596265e 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/TagHandlerTestSupport.java @@ -140,6 +140,14 @@ static class FakeTagTypeService extends TagTypeService { final List calls = new ArrayList<>(); /** The number of the next updates that lose a race with another writer. */ int updateConflicts; + /** The number of the next deletes that lose a race with another writer. */ + int deleteConflicts; + /** The tags whose deletes lose the race; all when null. */ + java.util.function.Predicate deleteConflictOn; + /** The change the other writer makes to the stored tag when a delete loses the race. */ + java.util.function.Consumer onDeleteConflict; + /** The deletes that fail with an error other than a conflict. */ + java.util.function.Predicate deleteFailure; @Override public OptionalEntity getTagType(final String id) { @@ -213,7 +221,27 @@ public void update(final TagType tagType) { @Override public void delete(final TagType tagType) { calls.add("delete " + tagType.getName() + "/" + tagType.getOwner()); - store.remove(tagType.getId()); + final String id = tagType.getId(); + if (tagType.getSeqNo() == null || tagType.getPrimaryTerm() == null) { + throw new IllegalArgumentException("no seqNo"); + } + if (deleteFailure != null && deleteFailure.test(tagType)) { + throw new IllegalStateException("delete failed: " + id); + } + if (deleteConflicts > 0 && (deleteConflictOn == null || deleteConflictOn.test(tagType))) { + deleteConflicts--; + // another writer changes the tag between the read and the delete + final TagType stored = store.get(id); + if (stored != null && onDeleteConflict != null) { + onDeleteConflict.accept(stored); + } + seqNoMap.merge(id, 1L, Long::sum); + throw new TagTypeConflictException("changed: " + id, null); + } + if (!store.containsKey(id) || !tagType.getSeqNo().equals(seqNoMap.get(id))) { + throw new TagTypeConflictException("changed: " + id, null); + } + store.remove(id); } } diff --git a/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java b/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java index a86d44c513..9c2248b3ba 100644 --- a/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java +++ b/src/test/java/org/codelibs/fess/api/v2/handlers/TagsHandlerTest.java @@ -253,6 +253,70 @@ public void test_put_rename_enqueuesRenameAndReturnsNewId() throws Exception { Assertions.assertEquals(List.of(TagChange.rename(old.getTagValue(), renamed.getTagValue())), env.helper.changes); } + @Test + public void test_put_rename_retriesWhenTheOldTagChangedConcurrently() throws Exception { + env.user("alice"); + final TagType old = env.put("foo", "alice", false, "http://a/"); + env.service.deleteConflicts = 1; + // a concurrent attach to the old tag between the read and the delete + env.service.onDeleteConflict = stored -> stored.setPaths(new String[] { "http://a/", "http://b/" }); + final Response res = call(request("PUT").json("{\"name\":\"bar\"}"), old.getId()); + Assertions.assertEquals(200, res.status, res.body()); + final TagType renamed = env.stored("bar", "alice"); + Assertions.assertEquals(list("http://a/", "http://b/"), list(renamed.getPaths())); + Assertions.assertNull(env.service.store.get(old.getId())); + Assertions.assertEquals(List.of(TagChange.rename(old.getTagValue(), renamed.getTagValue())), env.helper.changes); + } + + @Test + public void test_put_rename_keepsLosingTheRace_returnsConflictAndRollsBack() throws Exception { + env.user("alice"); + final TagType old = env.put("foo", "alice", false, "http://a/"); + env.service.deleteConflicts = 100; + env.service.deleteConflictOn = t -> "foo".equals(t.getName()); + final Response res = call(request("PUT").json("{\"name\":\"bar\"}"), old.getId()); + Assertions.assertEquals(409, res.status, res.body()); + Assertions.assertEquals("the tag was changed concurrently; try again", res.errorMessage()); + Assertions.assertNull(env.stored("bar", "alice"), "the new tag is rolled back"); + Assertions.assertNotNull(env.service.store.get(old.getId())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_put_rename_deleteFailure_rollsBackTheNewTag() throws Exception { + env.user("alice"); + final TagType old = env.put("foo", "alice", false, "http://a/"); + env.service.deleteFailure = t -> "foo".equals(t.getName()); + final Response res = call(request("PUT").json("{\"name\":\"bar\"}"), old.getId()); + Assertions.assertEquals(500, res.status, res.body()); + Assertions.assertNull(env.stored("bar", "alice"), "the new tag is rolled back"); + Assertions.assertNotNull(env.service.store.get(old.getId())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + + @Test + public void test_delete_retriesWhenTheTagChangedConcurrently() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + env.service.deleteConflicts = 1; + final Response res = call(request("DELETE"), tag.getId()); + Assertions.assertEquals(200, res.status, res.body()); + Assertions.assertNull(env.service.store.get(tag.getId())); + Assertions.assertEquals(List.of(TagChange.delete(tag.getTagValue())), env.helper.changes); + } + + @Test + public void test_delete_keepsLosingTheRace_returnsConflict() throws Exception { + env.user("alice"); + final TagType tag = env.put("foo", "alice", false, "http://a/"); + env.service.deleteConflicts = 100; + final Response res = call(request("DELETE"), tag.getId()); + Assertions.assertEquals(409, res.status, res.body()); + Assertions.assertEquals("conflict", res.errorCode()); + Assertions.assertNotNull(env.service.store.get(tag.getId())); + Assertions.assertEquals(List.of(), env.helper.changes); + } + @Test public void test_put_renameToExistingName_returnsConflictAndKeepsTheOldTag() throws Exception { env.user("alice"); diff --git a/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java index 9d69a58015..9cd3c50569 100644 --- a/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java +++ b/src/test/java/org/codelibs/fess/app/service/TagTypeServiceTest.java @@ -67,6 +67,23 @@ public void insert(final TagType entity, final RequestOptionCall opLambda) { + final org.codelibs.fesen.opensearch.action.delete.DeleteRequestBuilder builder = + new org.codelibs.fesen.opensearch.action.delete.DeleteRequestBuilder(null, + org.codelibs.fesen.opensearch.action.delete.DeleteAction.INSTANCE, null); + if (opLambda != null) { + opLambda.callback(builder); + } + deleteRequest = builder.request(); + if (failure != null) { + throw failure; + } + } + @Override public void update(final TagType entity, final RequestOptionCall opLambda) { updateRequest = apply(opLambda); @@ -152,6 +169,40 @@ public void test_update_conflictOverHttpThrowsTagTypeConflictException() { assertThrows(TagTypeConflictException.class, () -> createService(bhv).update(tagType)); } + @Test + public void test_delete_passesSeqNoAndPrimaryTerm() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + final TagType tagType = createTagType(); + tagType.setSeqNo(5L); + tagType.setPrimaryTerm(2L); + + createService(bhv).delete(tagType); + + assertEquals(5L, bhv.deleteRequest.ifSeqNo()); + assertEquals(2L, bhv.deleteRequest.ifPrimaryTerm()); + assertEquals(RefreshPolicy.IMMEDIATE, bhv.deleteRequest.getRefreshPolicy()); + } + + @Test + public void test_delete_withoutSeqNoIsRefused() { + // A delete without the sequence number would drop a concurrent change unseen. + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + + assertThrows(IllegalArgumentException.class, () -> createService(bhv).delete(createTagType())); + assertNull(bhv.deleteRequest); + } + + @Test + public void test_delete_conflictThrowsTagTypeConflictException() { + final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); + bhv.failure = new VersionConflictEngineException("[0123456789abcdef]: version conflict, required seqNo [5]"); + final TagType tagType = createTagType(); + tagType.setSeqNo(5L); + tagType.setPrimaryTerm(2L); + + assertThrows(TagTypeConflictException.class, () -> createService(bhv).delete(tagType)); + } + @Test public void test_insert_otherFailureIsRethrownAsIs() { final RecordingTagTypeBhv bhv = new RecordingTagTypeBhv(); From f53c1d954da83a4f94086195f4c04259a2dd9085 Mon Sep 17 00:00:00 2001 From: Shinsuke Sugaya Date: Sun, 4 Oct 2026 20:49:54 +0900 Subject: [PATCH 11/14] feat(admin): manage tags on the Tag admin screen Add the admin/tagtype screen and the api/admin/tagtype endpoints so that an administrator can list, create, edit and delete the tags of every user (roles admin-tagtype and admin-tagtype-view). - A form carries name, owner, paths (one URL per line), permissions, virtual host and sort order. The name is normalized like the v2 tag API, the owner is required and the number of paths is capped by user.tag.max.paths. Empty permissions default to the owner. - Edits and deletes send back the sequence number and primary term the tag was read with, so a change made since then by its owner or another administrator is reported instead of overwritten. - A change of the paths queues adding or removing the tag on the documents of those URLs; a change of the name or the owner replaces the tag and queues a rename; a delete queues the removal. Nothing is queued while user.tag.enabled is false. - TagTypeService#replace holds the create-then-delete step of a rename, with the rollback of the new tag, for both the admin screen and the v2 tag API. - Sidebar entry, AdminAction roles and redirect, labels and messages in all 17 locales, and the regenerated FessLabels, FessMessages and FessHtmlPath constants. --- .../fess/api/v2/handlers/TagsHandler.java | 27 +- .../fess/app/service/TagTypeService.java | 47 ++ .../fess/app/web/admin/AdminAction.java | 10 + .../web/admin/tagtype/AdminTagtypeAction.java | 584 ++++++++++++++++++ .../app/web/admin/tagtype/CreateForm.java | 90 +++ .../fess/app/web/admin/tagtype/EditForm.java | 57 ++ .../app/web/admin/tagtype/SearchForm.java | 39 ++ .../admin/tagtype/ApiAdminTagtypeAction.java | 187 ++++++ .../app/web/api/admin/tagtype/CreateBody.java | 31 + .../app/web/api/admin/tagtype/EditBody.java | 32 + .../app/web/api/admin/tagtype/SearchBody.java | 37 ++ .../fess/mylasta/action/FessHtmlPath.java | 9 + .../fess/mylasta/action/FessLabels.java | 21 + .../fess/mylasta/action/FessMessages.java | 70 +++ src/main/resources/fess_label.properties | 8 + src/main/resources/fess_label_de.properties | 8 + src/main/resources/fess_label_en.properties | 8 + src/main/resources/fess_label_es.properties | 8 + src/main/resources/fess_label_fr.properties | 8 + src/main/resources/fess_label_hi.properties | 8 + src/main/resources/fess_label_id.properties | 8 + src/main/resources/fess_label_it.properties | 8 + src/main/resources/fess_label_ja.properties | 8 + src/main/resources/fess_label_ko.properties | 8 + src/main/resources/fess_label_nl.properties | 8 + src/main/resources/fess_label_pl.properties | 8 + .../resources/fess_label_pt_BR.properties | 8 + src/main/resources/fess_label_ru.properties | 8 + src/main/resources/fess_label_tr.properties | 8 + .../resources/fess_label_zh_CN.properties | 8 + .../resources/fess_label_zh_TW.properties | 8 + src/main/resources/fess_message.properties | 4 + src/main/resources/fess_message_de.properties | 4 + src/main/resources/fess_message_en.properties | 4 + src/main/resources/fess_message_es.properties | 4 + src/main/resources/fess_message_fr.properties | 4 + src/main/resources/fess_message_hi.properties | 4 + src/main/resources/fess_message_id.properties | 4 + src/main/resources/fess_message_it.properties | 4 + src/main/resources/fess_message_ja.properties | 4 + src/main/resources/fess_message_ko.properties | 4 + src/main/resources/fess_message_nl.properties | 4 + src/main/resources/fess_message_pl.properties | 4 + .../resources/fess_message_pt_BR.properties | 4 + src/main/resources/fess_message_ru.properties | 4 + src/main/resources/fess_message_tr.properties | 4 + .../resources/fess_message_zh_CN.properties | 4 + .../resources/fess_message_zh_TW.properties | 4 + .../view/admin/tagtype/admin_tagtype.jsp | 170 +++++ .../admin/tagtype/admin_tagtype_details.jsp | 101 +++ .../view/admin/tagtype/admin_tagtype_edit.jsp | 121 ++++ .../WEB-INF/view/common/admin/sidebar.jsp | 9 +- .../fess/app/service/TagTypeServiceTest.java | 101 +++ .../admin/tagtype/AdminTagtypeActionTest.java | 273 ++++++++ .../tagtype/TagTypeAdminTestSupport.java | 236 +++++++ .../tagtype/ApiAdminTagtypeActionTest.java | 130 ++++ .../fess/mylasta/action/FessHtmlPathTest.java | 1 + 57 files changed, 2563 insertions(+), 24 deletions(-) create mode 100644 src/main/java/org/codelibs/fess/app/web/admin/tagtype/AdminTagtypeAction.java create mode 100644 src/main/java/org/codelibs/fess/app/web/admin/tagtype/CreateForm.java create mode 100644 src/main/java/org/codelibs/fess/app/web/admin/tagtype/EditForm.java create mode 100644 src/main/java/org/codelibs/fess/app/web/admin/tagtype/SearchForm.java create mode 100644 src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/ApiAdminTagtypeAction.java create mode 100644 src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/CreateBody.java create mode 100644 src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/EditBody.java create mode 100644 src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/SearchBody.java create mode 100644 src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype.jsp create mode 100644 src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_details.jsp create mode 100644 src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_edit.jsp create mode 100644 src/test/java/org/codelibs/fess/app/web/admin/tagtype/AdminTagtypeActionTest.java create mode 100644 src/test/java/org/codelibs/fess/app/web/admin/tagtype/TagTypeAdminTestSupport.java create mode 100644 src/test/java/org/codelibs/fess/app/web/api/admin/tagtype/ApiAdminTagtypeActionTest.java diff --git a/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java index 52766f3f91..fe84547716 100644 --- a/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java +++ b/src/main/java/org/codelibs/fess/api/v2/handlers/TagsHandler.java @@ -179,46 +179,27 @@ private TagType renameTag(final TagType read, final String name, final Boolean s renamed.setCreatedTime(current.getCreatedTime()); renamed.setUpdatedBy(userId); renamed.setUpdatedTime(ComponentUtil.getSystemHelper().getCurrentTimeAsLong()); + final boolean replaced; try { - getTagTypeService().insert(renamed); + replaced = getTagTypeService().replace(current, renamed); } catch (final TagTypeConflictException e) { throw new TagRequestException(V2ErrorCode.CONFLICT, "a tag with the name already exists"); } - try { - getTagTypeService().delete(current); - } catch (final TagTypeConflictException e) { - rollBack(renamed); + if (!replaced) { if (attempt >= MAX_UPDATE_ATTEMPTS) { - logger.warn("Failed to rename the tag after {} attempts: id={}", attempt, read.getId(), e); + logger.warn("Failed to rename the tag after {} attempts: id={}", attempt, read.getId()); throw new TagRequestException(V2ErrorCode.CONFLICT, "the tag was changed concurrently; try again"); } if (logger.isDebugEnabled()) { logger.debug("The tag was changed concurrently; retrying the rename: id={}, attempt={}", read.getId(), attempt); } continue; - } catch (final RuntimeException e) { - rollBack(renamed); - throw e; } enqueue(TagChange.rename(current.getTagValue(), renamed.getTagValue())); return renamed; } } - /** - * Deletes the tag created for a rename that could not delete the old tag, so that the caller is not left with - * both. A failure is logged; the caller can delete the leftover tag. - * - * @param inserted the tag created for the rename - */ - private void rollBack(final TagType inserted) { - try { - getTagTypeService().getTagType(inserted.getId()).ifPresent(getTagTypeService()::delete); - } catch (final RuntimeException e) { - logger.warn("Failed to remove the tag created for a rename: id={}", inserted.getId(), e); - } - } - /** * Adds the sharing roles to the permissions of a tag, or removes them. The owner's role and any * permission an administrator added are kept. diff --git a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java index 42648904d0..d554eeb16d 100644 --- a/src/main/java/org/codelibs/fess/app/service/TagTypeService.java +++ b/src/main/java/org/codelibs/fess/app/service/TagTypeService.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.Map; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.codelibs.core.beans.util.BeanUtil; import org.codelibs.core.lang.StringUtil; import org.codelibs.fesen.opensearch.search.aggregations.Aggregations; @@ -49,6 +51,8 @@ */ public class TagTypeService extends FessAppService { + private static final Logger logger = LogManager.getLogger(TagTypeService.class); + /** The name of the aggregations that count the paths of each tag. */ private static final String PATH_COUNT_AGGREGATION = "path_count"; @@ -252,6 +256,49 @@ public void delete(final TagType tagType) { } } + /** + * Replaces a tag type with one of another id, which a change of the name or the owner needs + * since the id is derived from them. The replacement is created first; then the tag type is + * deleted only if it did not change since it was read, so that a change made meanwhile, such as + * a URL added to its paths, is not dropped unseen. When the delete fails, the replacement is + * removed again so that both are not left behind. + * + * @param current The tag type to replace, carrying the sequence number and primary term it was read with. + * @param replacement The tag type to create, with its id set. + * @return true if the tag type was replaced; false if it changed since it was read, in which case nothing changed + * @throws TagTypeConflictException When a tag type with the id of the replacement already exists. + */ + public boolean replace(final TagType current, final TagType replacement) { + insert(replacement); + try { + delete(current); + return true; + } catch (final TagTypeConflictException e) { + if (logger.isDebugEnabled()) { + logger.debug("The tag type was changed concurrently: id={}", current.getId(), e); + } + removeReplacement(replacement); + return false; + } catch (final RuntimeException e) { + removeReplacement(replacement); + throw e; + } + } + + /** + * Deletes the tag type created by {@link #replace(TagType, TagType)} when the tag type it was to + * replace could not be deleted. A failure is logged; the leftover tag type can be deleted later. + * + * @param replacement The tag type created for the replacement. + */ + protected void removeReplacement(final TagType replacement) { + try { + getTagType(replacement.getId()).ifPresent(this::delete); + } catch (final RuntimeException e) { + logger.warn("Failed to remove the tag type created for a replacement: id={}", replacement.getId(), e); + } + } + /** * Fetches every field except {@code paths}. * diff --git a/src/main/java/org/codelibs/fess/app/web/admin/AdminAction.java b/src/main/java/org/codelibs/fess/app/web/admin/AdminAction.java index 328e116651..181d23dc0e 100644 --- a/src/main/java/org/codelibs/fess/app/web/admin/AdminAction.java +++ b/src/main/java/org/codelibs/fess/app/web/admin/AdminAction.java @@ -48,6 +48,7 @@ import org.codelibs.fess.app.web.admin.storage.AdminStorageAction; import org.codelibs.fess.app.web.admin.suggest.AdminSuggestAction; import org.codelibs.fess.app.web.admin.systeminfo.AdminSysteminfoAction; +import org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction; import org.codelibs.fess.app.web.admin.user.AdminUserAction; import org.codelibs.fess.app.web.admin.webauth.AdminWebauthAction; import org.codelibs.fess.app.web.admin.webconfig.AdminWebconfigAction; @@ -106,6 +107,8 @@ protected String getActionRole() { AdminDataconfigAction.ROLE + VIEW, // AdminLabeltypeAction.ROLE, // AdminLabeltypeAction.ROLE + VIEW, // + AdminTagtypeAction.ROLE, // + AdminTagtypeAction.ROLE + VIEW, // AdminKeymatchAction.ROLE, // AdminKeymatchAction.ROLE + VIEW, // AdminBoostdocAction.ROLE, // @@ -178,6 +181,8 @@ protected String getActionRole() { AdminDataconfigAction.ROLE + VIEW, // AdminLabeltypeAction.ROLE, // AdminLabeltypeAction.ROLE + VIEW, // + AdminTagtypeAction.ROLE, // + AdminTagtypeAction.ROLE + VIEW, // AdminKeymatchAction.ROLE, // AdminKeymatchAction.ROLE + VIEW, // AdminBoostdocAction.ROLE, // @@ -250,6 +255,8 @@ protected String getActionRole() { AdminDataconfigAction.ROLE + VIEW, // AdminLabeltypeAction.ROLE, // AdminLabeltypeAction.ROLE + VIEW, // + AdminTagtypeAction.ROLE, // + AdminTagtypeAction.ROLE + VIEW, // AdminKeymatchAction.ROLE, // AdminKeymatchAction.ROLE + VIEW, // AdminBoostdocAction.ROLE, // @@ -352,6 +359,9 @@ public static Class getAdminActionClass(final FessUse if (user.hasRoles(getActionRoles(AdminLabeltypeAction.ROLE))) { return AdminLabeltypeAction.class; } + if (user.hasRoles(getActionRoles(AdminTagtypeAction.ROLE))) { + return AdminTagtypeAction.class; + } if (user.hasRoles(getActionRoles(AdminKeymatchAction.ROLE))) { return AdminKeymatchAction.class; } diff --git a/src/main/java/org/codelibs/fess/app/web/admin/tagtype/AdminTagtypeAction.java b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/AdminTagtypeAction.java new file mode 100644 index 0000000000..ae371fae6e --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/AdminTagtypeAction.java @@ -0,0 +1,584 @@ +/* + * Copyright 2012-2025 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.app.web.admin.tagtype; + +import static org.codelibs.core.stream.StreamUtil.stream; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.core.lang.StringUtil; +import org.codelibs.fess.Constants; +import org.codelibs.fess.annotation.Secured; +import org.codelibs.fess.app.pager.TagTypePager; +import org.codelibs.fess.app.service.RoleTypeService; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.app.web.CrudMode; +import org.codelibs.fess.app.web.base.FessAdminAction; +import org.codelibs.fess.entity.TagChange; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.helper.PermissionHelper; +import org.codelibs.fess.helper.SystemHelper; +import org.codelibs.fess.helper.TagTypeHelper; +import org.codelibs.fess.mylasta.action.FessMessages; +import org.codelibs.fess.mylasta.direction.FessConfig; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.codelibs.fess.util.ComponentUtil; +import org.codelibs.fess.util.RenderDataUtil; +import org.dbflute.optional.OptionalEntity; +import org.dbflute.optional.OptionalThing; +import org.lastaflute.web.Execute; +import org.lastaflute.web.response.HtmlResponse; +import org.lastaflute.web.response.render.RenderData; +import org.lastaflute.web.ruts.process.ActionRuntime; + +import jakarta.annotation.Resource; + +/** + * Admin action for the tags users own. An administrator manages the tags of every user. + * + *

    The id of a tag is derived from its name and owner, so a change of either replaces the tag + * with a new one and renames the tag value on the documents. A change of the paths adds the tag + * to, or removes it from, the documents of the changed URLs. Those document changes are queued + * only while {@code user.tag.enabled} is true; otherwise the {@code tag_updater} job rebuilds the + * tag field later. Every write checks that the tag did not change since it was read.

    + */ +public class AdminTagtypeAction extends FessAdminAction { + + /** + * Default constructor. + */ + public AdminTagtypeAction() { + } + + /** The role name for tag type administration. */ + public static final String ROLE = "admin-tagtype"; + + /** Logger for this class. */ + private static final Logger logger = LogManager.getLogger(AdminTagtypeAction.class); + + /** Thrown when a tag of the same name and owner exists already. */ + public static class TagTypeExistsException extends TagTypeConflictException { + + private static final long serialVersionUID = 1L; + + /** + * Creates the exception. + * + * @param message the message + * @param cause the cause + */ + public TagTypeExistsException(final String message, final Throwable cause) { + super(message, cause); + } + } + + // =================================================================================== + // Attribute + // ========= + /** Service for tag type operations. */ + @Resource + private TagTypeService tagTypeService; + + /** Pager for tag type list pagination. */ + @Resource + private TagTypePager tagTypePager; + + /** Service for role type operations. */ + @Resource + private RoleTypeService roleTypeService; + + // =================================================================================== + // Hook + // ====== + @Override + protected void setupHtmlData(final ActionRuntime runtime) { + super.setupHtmlData(runtime); + runtime.registerData("helpLink", systemHelper.getHelpLink(fessConfig.getOnlineHelpNameTagtype())); + } + + @Override + protected String getActionRole() { + return ROLE; + } + + // =================================================================================== + // Search Execute + // ============== + /** + * Displays the tag type list page. + * + * @param form the search form + * @return HTML response for the list page + */ + @Execute + @Secured({ ROLE, ROLE + VIEW }) + public HtmlResponse index(final SearchForm form) { + return asListHtml(); + } + + /** + * Displays the tag type list with pagination. + * + * @param pageNumber the page number + * @param form the search form + * @return HTML response for the list page + */ + @Execute + @Secured({ ROLE, ROLE + VIEW }) + public HtmlResponse list(final OptionalThing pageNumber, final SearchForm form) { + pageNumber.ifPresent(num -> { + tagTypePager.setCurrentPageNumber(pageNumber.get()); + }).orElse(() -> { + tagTypePager.setCurrentPageNumber(0); + }); + return asHtml(path_AdminTagtype_AdminTagtypeJsp).renderWith(data -> { + searchPaging(data, form); + }); + } + + /** + * Searches tag types by name and owner. + * + * @param form the search form + * @return HTML response for the search results + */ + @Execute + @Secured({ ROLE, ROLE + VIEW }) + public HtmlResponse search(final SearchForm form) { + copyBeanToBean(form, tagTypePager, op -> op.exclude(Constants.PAGER_CONVERSION_RULE)); + return asHtml(path_AdminTagtype_AdminTagtypeJsp).renderWith(data -> { + searchPaging(data, form); + }); + } + + /** + * Resets the search criteria and displays the default list. + * + * @param form the search form + * @return HTML response for the reset list + */ + @Execute + @Secured({ ROLE, ROLE + VIEW }) + public HtmlResponse reset(final SearchForm form) { + tagTypePager.clear(); + return asHtml(path_AdminTagtype_AdminTagtypeJsp).renderWith(data -> { + searchPaging(data, form); + }); + } + + /** + * Sets up data for search result pagination. + * + * @param data the render data + * @param form the search form + */ + protected void searchPaging(final RenderData data, final SearchForm form) { + RenderDataUtil.register(data, "tagTypeItems", tagTypeService.getTagTypeList(tagTypePager)); // page navi + + // restore from pager + copyBeanToBean(tagTypePager, form, op -> op.include("name", "owner")); + } + + // =================================================================================== + // Edit Execute + // ============ + /** + * Displays the create new tag type page. + * + * @return HTML response for the create page + */ + @Execute + @Secured({ ROLE }) + public HtmlResponse createnew() { + saveToken(); + return asHtml(path_AdminTagtype_AdminTagtypeEditJsp).useForm(CreateForm.class, op -> { + op.setup(form -> { + form.initialize(); + form.crudMode = CrudMode.CREATE; + }); + }).renderWith(data -> { + registerRoleTypeItems(data); + }); + } + + /** + * Displays the edit tag type page. + * + * @param form the edit form + * @return HTML response for the edit page + */ + @Execute + @Secured({ ROLE }) + public HtmlResponse edit(final EditForm form) { + validate(form, messages -> {}, this::asListHtml); + final String id = form.id; + tagTypeService.getTagType(id).ifPresent(entity -> { + copyToForm(entity, form); + }).orElse(() -> { + throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asListHtml); + }); + saveToken(); + if (form.crudMode.intValue() == CrudMode.EDIT) { + // back + form.crudMode = CrudMode.DETAILS; + return asDetailsHtml(); + } + form.crudMode = CrudMode.EDIT; + return asEditHtml(); + } + + /** + * Displays the tag type details page. + * + * @param crudMode the CRUD mode + * @param id the tag type ID + * @return HTML response for the details page + */ + @Execute + @Secured({ ROLE, ROLE + VIEW }) + public HtmlResponse details(final int crudMode, final String id) { + verifyCrudMode(crudMode, CrudMode.DETAILS, this::asListHtml); + saveToken(); + return asHtml(path_AdminTagtype_AdminTagtypeDetailsJsp).useForm(EditForm.class, op -> { + op.setup(form -> { + tagTypeService.getTagType(id).ifPresent(entity -> { + copyToForm(entity, form); + form.crudMode = crudMode; + }).orElse(() -> { + throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asListHtml); + }); + }); + }).renderWith(data -> { + registerRoleTypeItems(data); + }); + } + + // ----------------------------------------------------- + // Actually Crud + // ------------- + /** + * Creates a new tag type. + * + * @param form the create form + * @return HTML response after creation + */ + @Execute + @Secured({ ROLE }) + public HtmlResponse create(final CreateForm form) { + verifyCrudMode(form.crudMode, CrudMode.CREATE, this::asListHtml); + validate(form, messages -> validateTagType(form, messages), this::asEditHtml); + verifyToken(this::asEditHtml); + try { + createTagType(tagTypeService, form); + saveInfo(messages -> messages.addSuccessCrudCreateCrudTable(GLOBAL)); + } catch (final TagTypeExistsException e) { + throwValidationError(messages -> messages.addErrorsTagtypeAlreadyExists(GLOBAL), this::asEditHtml); + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationError(messages -> messages.addErrorsCrudFailedToCreateCrudTable(GLOBAL, buildThrowableMessage(e)), + this::asEditHtml); + } + return redirect(getClass()); + } + + /** + * Updates an existing tag type. + * + * @param form the edit form + * @return HTML response after update + */ + @Execute + @Secured({ ROLE }) + public HtmlResponse update(final EditForm form) { + verifyCrudMode(form.crudMode, CrudMode.EDIT, this::asListHtml); + validate(form, messages -> validateTagType(form, messages), this::asEditHtml); + verifyToken(this::asEditHtml); + final OptionalEntity result; + try { + result = updateTagType(tagTypeService, form); + } catch (final TagTypeExistsException e) { + throwValidationError(messages -> messages.addErrorsTagtypeAlreadyExists(GLOBAL), this::asEditHtml); + return null; + } catch (final TagTypeConflictException e) { + throwValidationError(messages -> messages.addErrorsTagtypeChangedConcurrently(GLOBAL), this::asEditHtml); + return null; + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationError(messages -> messages.addErrorsCrudFailedToUpdateCrudTable(GLOBAL, buildThrowableMessage(e)), + this::asEditHtml); + return null; + } + result.ifPresent(entity -> { + saveInfo(messages -> messages.addSuccessCrudUpdateCrudTable(GLOBAL)); + }).orElse(() -> { + throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, form.id), this::asEditHtml); + }); + return redirect(getClass()); + } + + /** + * Deletes a tag type. + * + * @param form the edit form + * @return HTML response after deletion + */ + @Execute + @Secured({ ROLE }) + public HtmlResponse delete(final EditForm form) { + verifyCrudMode(form.crudMode, CrudMode.DETAILS, this::asListHtml); + validate(form, messages -> {}, this::asDetailsHtml); + verifyToken(this::asDetailsHtml); + final String id = form.id; + tagTypeService.getTagType(id).ifPresent(entity -> { + // delete the tag only as it was shown + entity.setSeqNo(form.seqNo); + entity.setPrimaryTerm(form.primaryTerm); + try { + deleteTagType(tagTypeService, entity); + saveInfo(messages -> messages.addSuccessCrudDeleteCrudTable(GLOBAL)); + } catch (final TagTypeConflictException e) { + throwValidationError(messages -> messages.addErrorsTagtypeChangedConcurrently(GLOBAL), this::asDetailsHtml); + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationError(messages -> messages.addErrorsCrudFailedToDeleteCrudTable(GLOBAL, buildThrowableMessage(e)), + this::asDetailsHtml); + } + }).orElse(() -> { + throwValidationError(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id), this::asDetailsHtml); + }); + return redirect(getClass()); + } + + // =================================================================================== + // Assist Logic + // ============ + /** + * Validates the fields of a tag type that the annotations of the form cannot: the name must be + * a valid tag name, the owner must not be blank and the number of paths must not exceed + * {@code user.tag.max.paths}. + * + * @param form the form + * @param messages the messages to add the errors to + */ + public static void validateTagType(final CreateForm form, final FessMessages messages) { + final FessConfig fessConfig = ComponentUtil.getFessConfig(); + if (form.name != null && ComponentUtil.getTagTypeHelper().normalizeName(form.name).isEmpty()) { + messages.addErrorsTagtypeInvalidName("name", String.valueOf(fessConfig.getUserTagNameMaxLengthAsInteger())); + } + if (form.owner != null && StringUtil.isBlank(form.owner)) { + messages.addConstraintsRequiredMessage("owner"); + } + final int maxPaths = fessConfig.getUserTagMaxPathsAsInteger(); + if (splitPaths(form.paths).length > maxPaths) { + messages.addErrorsTagtypeTooManyPaths("paths", String.valueOf(maxPaths)); + } + } + + /** + * Splits the paths of a form: one URL per line, trimmed, without blank lines and duplicates. + * + * @param paths the paths as entered + * @return the paths + */ + public static String[] splitPaths(final String paths) { + if (StringUtil.isBlank(paths)) { + return new String[0]; + } + return Arrays.stream(paths.split("\r\n|[\r\n]")).map(String::trim).filter(StringUtil::isNotBlank).distinct().toArray(String[]::new); + } + + /** + * Creates a tag type from a validated form and queues adding the tag to the documents of its paths. + * + * @param tagTypeService the tag type service + * @param form the validated form + * @return the created tag type + * @throws TagTypeExistsException if a tag of the same name and owner exists + */ + public static TagType createTagType(final TagTypeService tagTypeService, final CreateForm form) { + final SystemHelper systemHelper = ComponentUtil.getSystemHelper(); + final String username = systemHelper.getUsername(); + final long currentTime = systemHelper.getCurrentTimeAsLong(); + final TagType tagType = new TagType(); + tagType.setCreatedBy(username); + tagType.setCreatedTime(currentTime); + copyToEntity(form, tagType, username, currentTime); + try { + tagTypeService.insert(tagType); + } catch (final TagTypeConflictException e) { + throw new TagTypeExistsException("The tag type already exists: id=" + tagType.getId(), e); + } + final String value = tagType.getTagValue(); + for (final String path : tagType.getPaths()) { + enqueue(TagChange.add(value, path)); + } + return tagType; + } + + /** + * Updates a tag type from a validated form and queues the changes of the documents: a rename + * when the name or the owner changed, then the tag added to the documents of new paths and + * removed from those of dropped ones. The write fails when the tag type changed since the + * version the form carries was read. + * + * @param tagTypeService the tag type service + * @param form the validated form + * @return the updated tag type, or empty when there is no tag type of the id + * @throws TagTypeExistsException if the name or owner changed to those of another tag + * @throws TagTypeConflictException if the tag type changed since it was read + */ + public static OptionalEntity updateTagType(final TagTypeService tagTypeService, final EditForm form) { + return tagTypeService.getTagType(form.id).map(current -> { + current.setSeqNo(form.seqNo); + current.setPrimaryTerm(form.primaryTerm); + final String oldValue = current.getTagValue(); + final String[] oldPaths = current.getPaths() == null ? new String[0] : current.getPaths(); + final SystemHelper systemHelper = ComponentUtil.getSystemHelper(); + final TagType edited = new TagType(); + edited.setCreatedBy(current.getCreatedBy()); + edited.setCreatedTime(current.getCreatedTime()); + copyToEntity(form, edited, systemHelper.getUsername(), systemHelper.getCurrentTimeAsLong()); + final String newValue = edited.getTagValue(); + if (oldValue.equals(newValue)) { + edited.setSeqNo(current.getSeqNo()); + edited.setPrimaryTerm(current.getPrimaryTerm()); + tagTypeService.update(edited); + } else { + final boolean replaced; + try { + replaced = tagTypeService.replace(current, edited); + } catch (final TagTypeConflictException e) { + throw new TagTypeExistsException("The tag type already exists: id=" + edited.getId(), e); + } + if (!replaced) { + throw new TagTypeConflictException("The tag type was changed concurrently: id=" + current.getId(), null); + } + enqueue(TagChange.rename(oldValue, newValue)); + } + final Set oldPathSet = new LinkedHashSet<>(Arrays.asList(oldPaths)); + final Set newPathSet = new LinkedHashSet<>(Arrays.asList(edited.getPaths())); + newPathSet.stream().filter(path -> !oldPathSet.contains(path)).forEach(path -> enqueue(TagChange.add(newValue, path))); + oldPathSet.stream().filter(path -> !newPathSet.contains(path)).forEach(path -> enqueue(TagChange.remove(newValue, path))); + return edited; + }); + } + + /** + * Deletes a tag type and queues removing the tag from the documents. + * + * @param tagTypeService the tag type service + * @param tagType the tag type with the sequence number and primary term it was read with + * @throws TagTypeConflictException if the tag type changed since it was read + */ + public static void deleteTagType(final TagTypeService tagTypeService, final TagType tagType) { + tagTypeService.delete(tagType); + enqueue(TagChange.delete(tagType.getTagValue())); + } + + /** + * Copies a tag type to a form: the paths and the decoded permissions one per line, and the + * sequence number and primary term that a later write checks. + * + * @param entity the tag type + * @param form the form + */ + public static void copyToForm(final TagType entity, final EditForm form) { + form.id = entity.getId(); + form.seqNo = entity.getSeqNo(); + form.primaryTerm = entity.getPrimaryTerm(); + form.name = entity.getName(); + form.owner = entity.getOwner(); + form.paths = entity.getPaths() == null ? null : String.join("\n", entity.getPaths()); + final PermissionHelper permissionHelper = ComponentUtil.getPermissionHelper(); + form.permissions = stream(entity.getPermissions()).get( + stream -> stream.map(permissionHelper::decode).filter(StringUtil::isNotBlank).distinct().collect(Collectors.joining("\n"))); + form.virtualHost = entity.getVirtualHost(); + form.sortOrder = entity.getSortOrder(); + } + + /** + * Copies a validated form to a tag type and sets its id. The permissions default to the owner. + * + * @param form the form + * @param tagType the tag type + * @param username the user who makes the change + * @param currentTime the time of the change + */ + protected static void copyToEntity(final CreateForm form, final TagType tagType, final String username, final long currentTime) { + final TagTypeHelper tagTypeHelper = ComponentUtil.getTagTypeHelper(); + final String owner = form.owner.trim(); + tagType.setName(tagTypeHelper.normalizeName(form.name).orElseThrow(() -> new IllegalArgumentException("invalid tag name"))); + tagType.setOwner(owner); + tagType.setId(tagTypeHelper.toId(tagType.getTagValue())); + tagType.setPaths(splitPaths(form.paths)); + final String[] permissions = encodePermissions(form.permissions); + tagType.setPermissions(permissions.length == 0 ? tagTypeHelper.buildPermissions(owner, false) : permissions); + tagType.setVirtualHost(StringUtil.isBlank(form.virtualHost) ? StringUtil.EMPTY : form.virtualHost.trim()); + tagType.setSortOrder(form.sortOrder == null ? 0 : form.sortOrder); + tagType.setUpdatedBy(username); + tagType.setUpdatedTime(currentTime); + } + + /** + * Queues a change of the tag field of the documents while user tags are enabled. + * + * @param change the change + */ + protected static void enqueue(final TagChange change) { + if (ComponentUtil.getFessConfig().isUserTagEnabled()) { + ComponentUtil.getTagTypeHelper().enqueue(change); + } + } + + /** + * Registers role type items for the dropdown list. + * + * @param data the render data + */ + protected void registerRoleTypeItems(final RenderData data) { + RenderDataUtil.register(data, "roleTypeItems", roleTypeService.getRoleTypeList()); + } + + // =================================================================================== + // JSP + // ========= + private HtmlResponse asListHtml() { + return asHtml(path_AdminTagtype_AdminTagtypeJsp).renderWith(data -> { + RenderDataUtil.register(data, "tagTypeItems", tagTypeService.getTagTypeList(tagTypePager)); // page navi + }).useForm(SearchForm.class, setup -> { + setup.setup(form -> { + copyBeanToBean(tagTypePager, form, op -> op.include("name", "owner")); + }); + }); + } + + private HtmlResponse asEditHtml() { + return asHtml(path_AdminTagtype_AdminTagtypeEditJsp).renderWith(data -> { + registerRoleTypeItems(data); + }); + } + + private HtmlResponse asDetailsHtml() { + return asHtml(path_AdminTagtype_AdminTagtypeDetailsJsp).renderWith(data -> { + registerRoleTypeItems(data); + }); + } +} diff --git a/src/main/java/org/codelibs/fess/app/web/admin/tagtype/CreateForm.java b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/CreateForm.java new file mode 100644 index 0000000000..7208650fa9 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/CreateForm.java @@ -0,0 +1,90 @@ +/* + * Copyright 2012-2025 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.app.web.admin.tagtype; + +import org.codelibs.fess.app.web.CrudMode; +import org.codelibs.fess.validation.CustomSize; +import org.lastaflute.web.validation.Required; +import org.lastaflute.web.validation.theme.conversion.ValidateTypeFailure; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; + +/** + * The create form for Tag Type. + */ +public class CreateForm { + + /** + * Creates a new CreateForm instance. + */ + public CreateForm() { + } + + /** + * The CRUD mode for the form. + */ + @ValidateTypeFailure + public Integer crudMode; + + /** + * The name of the tag, normalized when it is stored. + */ + @Required + @Size(max = 1000) + public String name; + + /** + * The user who owns the tag. + */ + @Required + @Size(max = 1000) + public String owner; + + /** + * The URLs of the tagged documents, one per line. + */ + public String paths; + + /** + * The permissions required to view this tag, one per line. The owner only when empty. + */ + @CustomSize(maxKey = "form.admin.max.input.size") + public String permissions; + + /** + * The virtual host for the tag. + */ + @Size(max = 1000) + public String virtualHost; + + /** + * The sort order for displaying this tag. + */ + @Min(value = 0) + @Max(value = 2147483647) + @ValidateTypeFailure + public Integer sortOrder; + + /** + * Initializes the form with default values. + */ + public void initialize() { + crudMode = CrudMode.CREATE; + sortOrder = 0; + } +} diff --git a/src/main/java/org/codelibs/fess/app/web/admin/tagtype/EditForm.java b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/EditForm.java new file mode 100644 index 0000000000..d0b7625a51 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/EditForm.java @@ -0,0 +1,57 @@ +/* + * Copyright 2012-2025 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.app.web.admin.tagtype; + +import org.lastaflute.web.validation.Required; +import org.lastaflute.web.validation.theme.conversion.ValidateTypeFailure; + +import jakarta.validation.constraints.Size; + +/** + * Form class for editing tag types in the admin interface. The sequence number and primary term + * of the tag type as it was read are sent back, so that a change made since then by its owner or + * another administrator is reported instead of overwritten. + */ +public class EditForm extends CreateForm { + + /** + * Creates a new EditForm instance. + */ + public EditForm() { + } + + /** + * The unique identifier of the tag type being edited. + */ + @Required + @Size(max = 1000) + public String id; + + /** + * The sequence number of the tag type as it was read. + */ + @Required + @ValidateTypeFailure + public Long seqNo; + + /** + * The primary term of the tag type as it was read. + */ + @Required + @ValidateTypeFailure + public Long primaryTerm; + +} diff --git a/src/main/java/org/codelibs/fess/app/web/admin/tagtype/SearchForm.java b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/SearchForm.java new file mode 100644 index 0000000000..dbcec09c77 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/admin/tagtype/SearchForm.java @@ -0,0 +1,39 @@ +/* + * Copyright 2012-2025 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.app.web.admin.tagtype; + +/** + * The search form for Tag Type. + */ +public class SearchForm { + + /** + * Default constructor for SearchForm. + */ + public SearchForm() { + } + + /** + * The name field for searching tag types. + */ + public String name; + + /** + * The owner field for searching tag types. + */ + public String owner; + +} diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/ApiAdminTagtypeAction.java b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/ApiAdminTagtypeAction.java new file mode 100644 index 0000000000..69875cd167 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/ApiAdminTagtypeAction.java @@ -0,0 +1,187 @@ +/* + * Copyright 2012-2025 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.app.web.api.admin.tagtype; + +import static org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.copyToForm; +import static org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.createTagType; +import static org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.deleteTagType; +import static org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.updateTagType; +import static org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.validateTagType; + +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.codelibs.fess.app.pager.TagTypePager; +import org.codelibs.fess.app.service.TagTypeService; +import org.codelibs.fess.app.web.CrudMode; +import org.codelibs.fess.app.web.admin.tagtype.AdminTagtypeAction.TagTypeExistsException; +import org.codelibs.fess.app.web.api.ApiResult; +import org.codelibs.fess.app.web.api.ApiResult.ApiConfigResponse; +import org.codelibs.fess.app.web.api.ApiResult.ApiResponse; +import org.codelibs.fess.app.web.api.ApiResult.ApiUpdateResponse; +import org.codelibs.fess.app.web.api.ApiResult.Status; +import org.codelibs.fess.app.web.api.admin.FessApiAdminAction; +import org.codelibs.fess.exception.TagTypeConflictException; +import org.codelibs.fess.opensearch.config.exentity.TagType; +import org.lastaflute.web.Execute; +import org.lastaflute.web.response.JsonResponse; + +import jakarta.annotation.Resource; + +/** + * API action for the admin management of the tags users own. A setting carries the + * {@code seq_no} and {@code primary_term} it was read with; an update sends them back and is + * refused when the tag changed since. + */ +public class ApiAdminTagtypeAction extends FessApiAdminAction { + + private static final Logger logger = LogManager.getLogger(ApiAdminTagtypeAction.class); + + /** + * Default constructor. + */ + public ApiAdminTagtypeAction() { + } + + /** Service for tag type operations. */ + @Resource + private TagTypeService tagTypeService; + + /** + * Lists tag types, without their paths. + * + * @param body the search body + * @return the tag types + */ + // GET /api/admin/tagtype/settings + // PUT /api/admin/tagtype/settings + @Execute + public JsonResponse settings(final SearchBody body) { + validateApi(body, messages -> {}); + final TagTypePager pager = copyBeanToNewBean(body, TagTypePager.class); + final List list = tagTypeService.getTagTypeList(pager); + return asJson( + new ApiResult.ApiConfigsResponse().settings(list.stream().map(this::createEditBody).collect(Collectors.toList())) + .total(pager.getAllRecordCount()) + .status(ApiResult.Status.OK) + .result()); + } + + /** + * Returns a tag type with its paths. + * + * @param id the tag type id + * @return the tag type + */ + // GET /api/admin/tagtype/setting/{id} + @Execute + public JsonResponse get$setting(final String id) { + return asJson(new ApiConfigResponse().setting(tagTypeService.getTagType(id).map(this::createEditBody).orElseGet(() -> { + throwValidationErrorApi(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id)); + return null; + })).status(Status.OK).result()); + } + + /** + * Creates a tag type. + * + * @param body the tag type + * @return the id of the created tag type + */ + // POST /api/admin/tagtype/setting + @Execute + public JsonResponse post$setting(final CreateBody body) { + validateApi(body, messages -> validateTagType(body, messages)); + body.crudMode = CrudMode.CREATE; + TagType tagType = null; + try { + tagType = createTagType(tagTypeService, body); + } catch (final TagTypeExistsException e) { + throwValidationErrorApi(messages -> messages.addErrorsTagtypeAlreadyExists(GLOBAL)); + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationErrorApi(messages -> messages.addErrorsCrudFailedToCreateCrudTable(GLOBAL, buildThrowableMessage(e))); + } + return asJson(new ApiUpdateResponse().id(tagType.getId()).created(true).status(Status.OK).result()); + } + + /** + * Updates a tag type. A change of the name or the owner gives the tag type a new id. + * + * @param body the tag type with the sequence number and primary term it was read with + * @return the id of the updated tag type + */ + // PUT /api/admin/tagtype/setting + @Execute + public JsonResponse put$setting(final EditBody body) { + validateApi(body, messages -> validateTagType(body, messages)); + body.crudMode = CrudMode.EDIT; + TagType tagType = null; + try { + tagType = updateTagType(tagTypeService, body).orElse(null); + } catch (final TagTypeExistsException e) { + throwValidationErrorApi(messages -> messages.addErrorsTagtypeAlreadyExists(GLOBAL)); + } catch (final TagTypeConflictException e) { + throwValidationErrorApi(messages -> messages.addErrorsTagtypeChangedConcurrently(GLOBAL)); + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationErrorApi(messages -> messages.addErrorsCrudFailedToUpdateCrudTable(GLOBAL, buildThrowableMessage(e))); + } + if (tagType == null) { + throwValidationErrorApi(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, body.id)); + } + return asJson(new ApiUpdateResponse().id(tagType.getId()).created(false).status(Status.OK).result()); + } + + /** + * Deletes a tag type. + * + * @param id the tag type id + * @return the result + */ + // DELETE /api/admin/tagtype/setting/{id} + @Execute + public JsonResponse delete$setting(final String id) { + tagTypeService.getTagType(id).ifPresent(entity -> { + try { + deleteTagType(tagTypeService, entity); + saveInfo(messages -> messages.addSuccessCrudDeleteCrudTable(GLOBAL)); + } catch (final TagTypeConflictException e) { + throwValidationErrorApi(messages -> messages.addErrorsTagtypeChangedConcurrently(GLOBAL)); + } catch (final Exception e) { + logger.warn("Failed to process a request.", e); + throwValidationErrorApi(messages -> messages.addErrorsCrudFailedToDeleteCrudTable(GLOBAL, buildThrowableMessage(e))); + } + }).orElse(() -> { + throwValidationErrorApi(messages -> messages.addErrorsCrudCouldNotFindCrudTable(GLOBAL, id)); + }); + return asJson(new ApiResponse().status(Status.OK).result()); + } + + /** + * Creates the body of a tag type. + * + * @param entity the tag type + * @return the body + */ + protected EditBody createEditBody(final TagType entity) { + final EditBody body = new EditBody(); + copyToForm(entity, body); + return body; + } +} diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/CreateBody.java b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/CreateBody.java new file mode 100644 index 0000000000..130dd536c7 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/CreateBody.java @@ -0,0 +1,31 @@ +/* + * Copyright 2012-2025 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.app.web.api.admin.tagtype; + +import org.codelibs.fess.app.web.admin.tagtype.CreateForm; + +/** + * The request body for creating a tag type through the admin API. + */ +public class CreateBody extends CreateForm { + + /** + * Default constructor. + */ + public CreateBody() { + } + +} diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/EditBody.java b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/EditBody.java new file mode 100644 index 0000000000..ade71ccd8c --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/EditBody.java @@ -0,0 +1,32 @@ +/* + * Copyright 2012-2025 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.app.web.api.admin.tagtype; + +import org.codelibs.fess.app.web.admin.tagtype.EditForm; + +/** + * The request body for updating a tag type through the admin API. It carries the + * {@code seq_no} and {@code primary_term} that a read of the tag type returned. + */ +public class EditBody extends EditForm { + + /** + * Default constructor. + */ + public EditBody() { + } + +} diff --git a/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/SearchBody.java b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/SearchBody.java new file mode 100644 index 0000000000..95b485f9e0 --- /dev/null +++ b/src/main/java/org/codelibs/fess/app/web/api/admin/tagtype/SearchBody.java @@ -0,0 +1,37 @@ +/* + * Copyright 2012-2025 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.app.web.api.admin.tagtype; + +import org.codelibs.fess.app.web.api.admin.BaseSearchBody; + +/** + * The request body for searching tag types through the admin API. + */ +public class SearchBody extends BaseSearchBody { + + /** + * Default constructor. + */ + public SearchBody() { + } + + /** The name to search for. */ + public String name; + + /** The owner to search for. */ + public String owner; + +} diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java b/src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java index b81095c638..00aef5d15f 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessHtmlPath.java @@ -370,6 +370,15 @@ public interface FessHtmlPath { /** The path of the HTML: /admin/systeminfo/admin_systeminfo.jsp */ HtmlNext path_AdminSysteminfo_AdminSysteminfoJsp = new HtmlNext("/admin/systeminfo/admin_systeminfo.jsp"); + /** The path of the HTML: /admin/tagtype/admin_tagtype.jsp */ + HtmlNext path_AdminTagtype_AdminTagtypeJsp = new HtmlNext("/admin/tagtype/admin_tagtype.jsp"); + + /** The path of the HTML: /admin/tagtype/admin_tagtype_details.jsp */ + HtmlNext path_AdminTagtype_AdminTagtypeDetailsJsp = new HtmlNext("/admin/tagtype/admin_tagtype_details.jsp"); + + /** The path of the HTML: /admin/tagtype/admin_tagtype_edit.jsp */ + HtmlNext path_AdminTagtype_AdminTagtypeEditJsp = new HtmlNext("/admin/tagtype/admin_tagtype_edit.jsp"); + /** The path of the HTML: /admin/theme/admin_theme.jsp */ HtmlNext path_AdminTheme_AdminThemeJsp = new HtmlNext("/admin/theme/admin_theme.jsp"); diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java index b4105daed1..7c568dff74 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessLabels.java @@ -1253,6 +1253,9 @@ public class FessLabels extends UserMessages { /** The key of the message: Label Type Details */ public static final String LABELS_labeltype_details = "{labels.labeltype_details}"; + /** The key of the message: Tag Details */ + public static final String LABELS_tagtype_details = "{labels.tagtype_details}"; + /** The key of the message: Path Mapping Details */ public static final String LABELS_pathmap_details = "{labels.pathmap_details}"; @@ -1325,6 +1328,9 @@ public class FessLabels extends UserMessages { /** The key of the message: Label Type List */ public static final String LABELS_labeltype_list = "{labels.labeltype_list}"; + /** The key of the message: Tag List */ + public static final String LABELS_tagtype_list = "{labels.tagtype_list}"; + /** The key of the message: Key Match List */ public static final String LABELS_key_match_list = "{labels.key_match_list}"; @@ -1574,6 +1580,21 @@ public class FessLabels extends UserMessages { /** The key of the message: Excluded Paths */ public static final String LABELS_labeltype_excluded_paths = "{labels.labeltype_excluded_paths}"; + /** The key of the message: Tag */ + public static final String LABELS_tagtype_configuration = "{labels.tagtype_configuration}"; + + /** The key of the message: Tag */ + public static final String LABELS_tagtype_title_details = "{labels.tagtype_title_details}"; + + /** The key of the message: Name */ + public static final String LABELS_tagtype_name = "{labels.tagtype_name}"; + + /** The key of the message: Owner */ + public static final String LABELS_tagtype_owner = "{labels.tagtype_owner}"; + + /** The key of the message: Paths */ + public static final String LABELS_tagtype_paths = "{labels.tagtype_paths}"; + /** The key of the message: Role */ public static final String LABELS_roletype_configuration = "{labels.roletype_configuration}"; diff --git a/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java b/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java index 1bdcafd874..fbaa586b5a 100644 --- a/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java +++ b/src/main/java/org/codelibs/fess/mylasta/action/FessMessages.java @@ -491,6 +491,18 @@ public class FessMessages extends FessLabels { /** The key of the message: Related queries are already being generated. */ public static final String ERRORS_related_query_generation_in_progress = "{errors.related_query_generation_in_progress}"; + /** The key of the message: Enter a tag name of 1 to {0} characters without control characters. */ + public static final String ERRORS_tagtype_invalid_name = "{errors.tagtype_invalid_name}"; + + /** The key of the message: A tag can have up to {0} paths. */ + public static final String ERRORS_tagtype_too_many_paths = "{errors.tagtype_too_many_paths}"; + + /** The key of the message: A tag with the same name and owner already exists. */ + public static final String ERRORS_tagtype_already_exists = "{errors.tagtype_already_exists}"; + + /** The key of the message: The tag was changed by someone else. Reload it and try again. */ + public static final String ERRORS_tagtype_changed_concurrently = "{errors.tagtype_changed_concurrently}"; + /** The key of the message: {0} is required. */ public static final String ERRORS_property_required = "{errors.property_required}"; @@ -2893,6 +2905,64 @@ public FessMessages addErrorsRelatedQueryGenerationInProgress(String property) { return this; } + /** + * Add the created action message for the key 'errors.tagtype_invalid_name' with parameters. + *
    +     * message: Enter a tag name of 1 to {0} characters without control characters.
    +     * 
    + * @param property The property name for the message. (NotNull) + * @param arg0 The parameter arg0 for message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsTagtypeInvalidName(String property, String arg0) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_tagtype_invalid_name, arg0)); + return this; + } + + /** + * Add the created action message for the key 'errors.tagtype_too_many_paths' with parameters. + *
    +     * message: A tag can have up to {0} paths.
    +     * 
    + * @param property The property name for the message. (NotNull) + * @param arg0 The parameter arg0 for message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsTagtypeTooManyPaths(String property, String arg0) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_tagtype_too_many_paths, arg0)); + return this; + } + + /** + * Add the created action message for the key 'errors.tagtype_already_exists' with parameters. + *
    +     * message: A tag with the same name and owner already exists.
    +     * 
    + * @param property The property name for the message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsTagtypeAlreadyExists(String property) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_tagtype_already_exists)); + return this; + } + + /** + * Add the created action message for the key 'errors.tagtype_changed_concurrently' with parameters. + *
    +     * message: The tag was changed by someone else. Reload it and try again.
    +     * 
    + * @param property The property name for the message. (NotNull) + * @return this. (NotNull) + */ + public FessMessages addErrorsTagtypeChangedConcurrently(String property) { + assertPropertyNotNull(property); + add(property, new UserMessage(ERRORS_tagtype_changed_concurrently)); + return this; + } + /** * Add the created action message for the key 'errors.property_required' with parameters. *
    diff --git a/src/main/resources/fess_label.properties b/src/main/resources/fess_label.properties
    index ee535ecfeb..6a51f09ed1 100644
    --- a/src/main/resources/fess_label.properties
    +++ b/src/main/resources/fess_label.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=File System
     labels.menu_data_store=Data Store
     labels.menu_label_type=Label
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Key Match
     labels.menu_boost_document_rule=Document Boost
     labels.menu_path_mapping=Path Mapping
    @@ -408,6 +409,7 @@ labels.group_details=Group Details
     labels.job_log_details=Job Log Details
     labels.key_match_details=Key Match Details
     labels.labeltype_details=Label Type Details
    +labels.tagtype_details=Tag Details
     labels.pathmap_details=Path Mapping Details
     labels.related_content_details=Related Content Details
     labels.related_query_details=Related Query Details
    @@ -432,6 +434,7 @@ labels.plugin_list=Plugin List
     labels.pathmap_list=Path Mapping List
     labels.log_file_list=Log File List
     labels.labeltype_list=Label Type List
    +labels.tagtype_list=Tag List
     labels.key_match_list=Key Match List
     labels.job_log_list=Job Log List
     labels.group_list=Group List
    @@ -515,6 +518,11 @@ labels.labeltype_name=Name
     labels.labeltype_value=Value
     labels.labeltype_included_paths=Included Paths
     labels.labeltype_excluded_paths=Excluded Paths
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Name
    +labels.tagtype_owner=Owner
    +labels.tagtype_paths=Paths
     labels.roletype_configuration=Role
     labels.roletype_title_details=Role
     labels.roletype_name=Name
    diff --git a/src/main/resources/fess_label_de.properties b/src/main/resources/fess_label_de.properties
    index d13ce835c1..aba0d94bca 100644
    --- a/src/main/resources/fess_label_de.properties
    +++ b/src/main/resources/fess_label_de.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=Dateisystem
     labels.menu_data_store=Datenspeicher
     labels.menu_label_type=Label
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Schlüsselübereinstimmung
     labels.menu_boost_document_rule=Dokument-Boost
     labels.menu_path_mapping=Pfadzuordnung
    @@ -408,6 +409,7 @@ labels.group_details=Gruppen-Details
     labels.job_log_details=Jobprotokoll-Details
     labels.key_match_details=Schlüsselübereinstimmungs-Details
     labels.labeltype_details=Label-Typ-Details
    +labels.tagtype_details=Tag-Details
     labels.pathmap_details=Pfadzuordnungs-Details
     labels.related_content_details=Verwandter-Inhalt-Details
     labels.related_query_details=Verwandte-Abfrage-Details
    @@ -432,6 +434,7 @@ labels.plugin_list=Plugin-Liste
     labels.pathmap_list=Pfadzuordnungs-Liste
     labels.log_file_list=Logdatei-Liste
     labels.labeltype_list=Label-Typ-Liste
    +labels.tagtype_list=Tag-Liste
     labels.key_match_list=Schlüsselübereinstimmungs-Liste
     labels.job_log_list=Job-Log-Liste
     labels.group_list=Gruppen-Liste
    @@ -515,6 +518,11 @@ labels.labeltype_name=Name
     labels.labeltype_value=Wert
     labels.labeltype_included_paths=Eingeschlossene Pfade
     labels.labeltype_excluded_paths=Ausgeschlossene Pfade
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Name
    +labels.tagtype_owner=Besitzer
    +labels.tagtype_paths=Pfade
     labels.roletype_configuration=Rolle
     labels.roletype_title_details=Rolle
     labels.roletype_name=Name
    diff --git a/src/main/resources/fess_label_en.properties b/src/main/resources/fess_label_en.properties
    index 0313f4741a..e8d2613f41 100644
    --- a/src/main/resources/fess_label_en.properties
    +++ b/src/main/resources/fess_label_en.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=File System
     labels.menu_data_store=Data Store
     labels.menu_label_type=Label
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Key Match
     labels.menu_boost_document_rule=Document Boost
     labels.menu_path_mapping=Path Mapping
    @@ -408,6 +409,7 @@ labels.group_details=Group Details
     labels.job_log_details=Job Log Details
     labels.key_match_details=Key Match Details
     labels.labeltype_details=Label Type Details
    +labels.tagtype_details=Tag Details
     labels.pathmap_details=Path Mapping Details
     labels.related_content_details=Related Content Details
     labels.related_query_details=Related Query Details
    @@ -432,6 +434,7 @@ labels.plugin_list=Plugin List
     labels.pathmap_list=Path Mapping List
     labels.log_file_list=Log File List
     labels.labeltype_list=Label Type List
    +labels.tagtype_list=Tag List
     labels.key_match_list=Key Match List
     labels.job_log_list=Job Log List
     labels.group_list=Group List
    @@ -515,6 +518,11 @@ labels.labeltype_name=Name
     labels.labeltype_value=Value
     labels.labeltype_included_paths=Included Paths
     labels.labeltype_excluded_paths=Excluded Paths
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Name
    +labels.tagtype_owner=Owner
    +labels.tagtype_paths=Paths
     labels.roletype_configuration=Role
     labels.roletype_title_details=Role
     labels.roletype_name=Name
    diff --git a/src/main/resources/fess_label_es.properties b/src/main/resources/fess_label_es.properties
    index e384f19298..4410f6689e 100644
    --- a/src/main/resources/fess_label_es.properties
    +++ b/src/main/resources/fess_label_es.properties
    @@ -148,6 +148,7 @@ labels.menu_web=Web
     labels.menu_file_system=Sistema de archivos
     labels.menu_data_store=Almacén de datos
     labels.menu_label_type=Etiqueta
    +labels.menu_tag_type=Etiqueta de usuario
     labels.menu_key_match=Coincidencia de clave
     labels.menu_boost_document_rule=Impulso de documento
     labels.menu_path_mapping=Mapeo de rutas
    @@ -407,6 +408,7 @@ labels.group_details=Group Detalles
     labels.job_log_details=Job Log Detalles
     labels.key_match_details=Key Match Detalles
     labels.labeltype_details=Labeltype Detalles
    +labels.tagtype_details=Detalles de la etiqueta de usuario
     labels.pathmap_details=Pathmap Detalles
     labels.related_content_details=Related Content Detalles
     labels.related_query_details=Related Query Detalles
    @@ -431,6 +433,7 @@ labels.plugin_list=Lista de complementos
     labels.pathmap_list=Lista de mapeo de rutas
     labels.log_file_list=Lista de archivos de registro
     labels.labeltype_list=Lista de tipos de etiquetas
    +labels.tagtype_list=Lista de etiquetas de usuario
     labels.key_match_list=Lista de coincidencias de claves
     labels.job_log_list=Lista de registros de trabajos
     labels.group_list=Lista de grupos
    @@ -514,6 +517,11 @@ labels.labeltype_name=Nombre
     labels.labeltype_value=Valor
     labels.labeltype_included_paths=Rutas incluidas
     labels.labeltype_excluded_paths=Rutas excluidas
    +labels.tagtype_configuration=Etiqueta de usuario
    +labels.tagtype_title_details=Etiqueta de usuario
    +labels.tagtype_name=Nombre
    +labels.tagtype_owner=Propietario
    +labels.tagtype_paths=Rutas
     labels.roletype_configuration=Rol
     labels.roletype_title_details=Rol
     labels.roletype_name=Nombre
    diff --git a/src/main/resources/fess_label_fr.properties b/src/main/resources/fess_label_fr.properties
    index 10fdfffeef..1529cccb2f 100644
    --- a/src/main/resources/fess_label_fr.properties
    +++ b/src/main/resources/fess_label_fr.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=Système de fichiers
     labels.menu_data_store=Magasin de données
     labels.menu_label_type=Étiquette
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Correspondance de clé
     labels.menu_boost_document_rule=Boost de document
     labels.menu_path_mapping=Mappage de chemin
    @@ -408,6 +409,7 @@ labels.group_details=Group Détails
     labels.job_log_details=Job Log Détails
     labels.key_match_details=Key Match Détails
     labels.labeltype_details=Labeltype Détails
    +labels.tagtype_details=Détails du tag
     labels.pathmap_details=Pathmap Détails
     labels.related_content_details=Related Content Détails
     labels.related_query_details=Related Query Détails
    @@ -432,6 +434,7 @@ labels.plugin_list=Liste des plugins
     labels.pathmap_list=Liste de mappage de chemins
     labels.log_file_list=Liste des fichiers journaux
     labels.labeltype_list=Liste des types d'étiquettes
    +labels.tagtype_list=Liste des tags
     labels.key_match_list=Liste de correspondance de clés
     labels.job_log_list=Liste des journaux de tâches
     labels.group_list=Liste des groupes
    @@ -515,6 +518,11 @@ labels.labeltype_name=Nom
     labels.labeltype_value=Valeur
     labels.labeltype_included_paths=Chemins inclus
     labels.labeltype_excluded_paths=Chemins exclus
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Nom
    +labels.tagtype_owner=Propriétaire
    +labels.tagtype_paths=Chemins
     labels.roletype_configuration=Rôle
     labels.roletype_title_details=Rôle
     labels.roletype_name=Nom
    diff --git a/src/main/resources/fess_label_hi.properties b/src/main/resources/fess_label_hi.properties
    index 0df079fa06..9677e2bebe 100644
    --- a/src/main/resources/fess_label_hi.properties
    +++ b/src/main/resources/fess_label_hi.properties
    @@ -149,6 +149,7 @@ labels.menu_web=वेब
     labels.menu_file_system=फ़ाइल सिस्टम
     labels.menu_data_store=डेटा स्टोर
     labels.menu_label_type=लेबल
    +labels.menu_tag_type=टैग
     labels.menu_key_match=की मैच
     labels.menu_boost_document_rule=दस्तावेज़ बूस्ट
     labels.menu_path_mapping=पथ मैपिंग
    @@ -408,6 +409,7 @@ labels.group_details=Group विवरण
     labels.job_log_details=Job Log विवरण
     labels.key_match_details=Key Match विवरण
     labels.labeltype_details=Labeltype विवरण
    +labels.tagtype_details=टैग विवरण
     labels.pathmap_details=Pathmap विवरण
     labels.related_content_details=Related Content विवरण
     labels.related_query_details=Related Query विवरण
    @@ -432,6 +434,7 @@ labels.plugin_list=प्लगइन सूची
     labels.pathmap_list=पथ मैपिंग सूची
     labels.log_file_list=लॉग फ़ाइल सूची
     labels.labeltype_list=लेबल प्रकार सूची
    +labels.tagtype_list=टैग सूची
     labels.key_match_list=की मैच सूची
     labels.job_log_list=जॉब लॉग सूची
     labels.group_list=समूह सूची
    @@ -515,6 +518,11 @@ labels.labeltype_name=नाम
     labels.labeltype_value=मान
     labels.labeltype_included_paths=शामिल पथ
     labels.labeltype_excluded_paths=बहिष्कृत पथ
    +labels.tagtype_configuration=टैग
    +labels.tagtype_title_details=टैग
    +labels.tagtype_name=नाम
    +labels.tagtype_owner=स्वामी
    +labels.tagtype_paths=पथ
     labels.roletype_configuration=भूमिका
     labels.roletype_title_details=भूमिका
     labels.roletype_name=नाम
    diff --git a/src/main/resources/fess_label_id.properties b/src/main/resources/fess_label_id.properties
    index d05dfb2ab6..4d21e93c41 100644
    --- a/src/main/resources/fess_label_id.properties
    +++ b/src/main/resources/fess_label_id.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=Sistem Berkas
     labels.menu_data_store=Penyimpanan Data
     labels.menu_label_type=Label
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Pencocokan Kata Kunci
     labels.menu_boost_document_rule=Boost Dokumen
     labels.menu_path_mapping=Pemetaan Jalur
    @@ -408,6 +409,7 @@ labels.group_details=Group Detail
     labels.job_log_details=Job Log Detail
     labels.key_match_details=Key Match Detail
     labels.labeltype_details=Labeltype Detail
    +labels.tagtype_details=Detail Tag
     labels.pathmap_details=Pathmap Detail
     labels.related_content_details=Related Content Detail
     labels.related_query_details=Related Query Detail
    @@ -432,6 +434,7 @@ labels.plugin_list=Daftar plugin
     labels.pathmap_list=Daftar pemetaan jalur
     labels.log_file_list=Daftar file log
     labels.labeltype_list=Daftar tipe label
    +labels.tagtype_list=Daftar Tag
     labels.key_match_list=Daftar kecocokan kunci
     labels.job_log_list=Daftar log pekerjaan
     labels.group_list=Daftar grup
    @@ -515,6 +518,11 @@ labels.labeltype_name=Nama
     labels.labeltype_value=Nilai
     labels.labeltype_included_paths=Jalur yang Disertakan
     labels.labeltype_excluded_paths=Jalur yang Dikecualikan
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Nama
    +labels.tagtype_owner=Pemilik
    +labels.tagtype_paths=Jalur
     labels.roletype_configuration=Peran
     labels.roletype_title_details=Peran
     labels.roletype_name=Nama
    diff --git a/src/main/resources/fess_label_it.properties b/src/main/resources/fess_label_it.properties
    index 9e7b4e3de2..ed1fe497a5 100644
    --- a/src/main/resources/fess_label_it.properties
    +++ b/src/main/resources/fess_label_it.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=File system
     labels.menu_data_store=Data store
     labels.menu_label_type=Etichetta
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Corrispondenza chiave
     labels.menu_boost_document_rule=Boost documento
     labels.menu_path_mapping=Mappatura percorso
    @@ -408,6 +409,7 @@ labels.group_details=Group Dettagli
     labels.job_log_details=Job Log Dettagli
     labels.key_match_details=Key Match Dettagli
     labels.labeltype_details=Labeltype Dettagli
    +labels.tagtype_details=Dettagli tag
     labels.pathmap_details=Pathmap Dettagli
     labels.related_content_details=Related Content Dettagli
     labels.related_query_details=Related Query Dettagli
    @@ -432,6 +434,7 @@ labels.plugin_list=Elenco plugin
     labels.pathmap_list=Elenco mappatura percorsi
     labels.log_file_list=Elenco file di log
     labels.labeltype_list=Elenco tipi etichetta
    +labels.tagtype_list=Elenco tag
     labels.key_match_list=Elenco corrispondenze chiave
     labels.job_log_list=Elenco log lavori
     labels.group_list=Elenco gruppi
    @@ -515,6 +518,11 @@ labels.labeltype_name=Nome
     labels.labeltype_value=Valore
     labels.labeltype_included_paths=Percorsi inclusi
     labels.labeltype_excluded_paths=Percorsi esclusi
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Nome
    +labels.tagtype_owner=Proprietario
    +labels.tagtype_paths=Percorsi
     labels.roletype_configuration=Ruolo
     labels.roletype_title_details=Ruolo
     labels.roletype_name=Nome
    diff --git a/src/main/resources/fess_label_ja.properties b/src/main/resources/fess_label_ja.properties
    index 43e9fa17cd..906abed49b 100644
    --- a/src/main/resources/fess_label_ja.properties
    +++ b/src/main/resources/fess_label_ja.properties
    @@ -149,6 +149,7 @@ labels.menu_web=ウェブ
     labels.menu_file_system=ファイルシステム
     labels.menu_data_store=データストア
     labels.menu_label_type=ラベル
    +labels.menu_tag_type=タグ
     labels.menu_key_match=キーマッチ
     labels.menu_boost_document_rule=ドキュメントブースト
     labels.menu_path_mapping=パスマッピング
    @@ -408,6 +409,7 @@ labels.group_details=グループ詳細
     labels.job_log_details=ジョブログ詳細
     labels.key_match_details=キーマッチ詳細
     labels.labeltype_details=ラベルタイプ詳細
    +labels.tagtype_details=タグ詳細
     labels.pathmap_details=パスマッピング詳細
     labels.related_content_details=関連コンテンツ詳細
     labels.related_query_details=関連クエリ詳細
    @@ -432,6 +434,7 @@ labels.plugin_list=プラグイン一覧
     labels.pathmap_list=パスマッピング一覧
     labels.log_file_list=ログファイル一覧
     labels.labeltype_list=ラベルタイプ一覧
    +labels.tagtype_list=タグ一覧
     labels.key_match_list=キーマッチ一覧
     labels.job_log_list=ジョブログ一覧
     labels.group_list=グループ一覧
    @@ -512,6 +515,11 @@ labels.labeltype_name=名前
     labels.labeltype_value=値
     labels.labeltype_included_paths=対象とするパス
     labels.labeltype_excluded_paths=除外するパス
    +labels.tagtype_configuration=タグ
    +labels.tagtype_title_details=タグ
    +labels.tagtype_name=名前
    +labels.tagtype_owner=所有者
    +labels.tagtype_paths=パス
     labels.roletype_configuration=ロール
     labels.roletype_title_details=ロール
     labels.roletype_name=名前
    diff --git a/src/main/resources/fess_label_ko.properties b/src/main/resources/fess_label_ko.properties
    index 341cd4cd5d..09d827c906 100644
    --- a/src/main/resources/fess_label_ko.properties
    +++ b/src/main/resources/fess_label_ko.properties
    @@ -149,6 +149,7 @@ labels.menu_web=웹
     labels.menu_file_system=파일 시스템
     labels.menu_data_store=데이터 저장소
     labels.menu_label_type=라벨
    +labels.menu_tag_type=태그
     labels.menu_key_match=키 매치
     labels.menu_boost_document_rule=문서 부스트
     labels.menu_path_mapping=경로 매핑
    @@ -408,6 +409,7 @@ labels.group_details=Group 상세
     labels.job_log_details=Job Log 상세
     labels.key_match_details=Key Match 상세
     labels.labeltype_details=Labeltype 상세
    +labels.tagtype_details=태그 상세
     labels.pathmap_details=Pathmap 상세
     labels.related_content_details=Related Content 상세
     labels.related_query_details=Related Query 상세
    @@ -432,6 +434,7 @@ labels.plugin_list=플러그인 목록
     labels.pathmap_list=경로 매핑 목록
     labels.log_file_list=로그 파일 목록
     labels.labeltype_list=라벨 유형 목록
    +labels.tagtype_list=태그 목록
     labels.key_match_list=키 매치 목록
     labels.job_log_list=작업 로그 목록
     labels.group_list=그룹 목록
    @@ -515,6 +518,11 @@ labels.labeltype_name=이름
     labels.labeltype_value=값
     labels.labeltype_included_paths=대상 경로
     labels.labeltype_excluded_paths=제외 경로
    +labels.tagtype_configuration=태그
    +labels.tagtype_title_details=태그
    +labels.tagtype_name=이름
    +labels.tagtype_owner=소유자
    +labels.tagtype_paths=경로
     labels.roletype_configuration=역할
     labels.roletype_title_details=역할
     labels.roletype_name=이름
    diff --git a/src/main/resources/fess_label_nl.properties b/src/main/resources/fess_label_nl.properties
    index b2ec37182e..ed63c9765a 100644
    --- a/src/main/resources/fess_label_nl.properties
    +++ b/src/main/resources/fess_label_nl.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=Bestandssysteem
     labels.menu_data_store=Gegevensopslag
     labels.menu_label_type=Label
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Sleutelovereenkomst
     labels.menu_boost_document_rule=Documentboost
     labels.menu_path_mapping=Padtoewijzing
    @@ -408,6 +409,7 @@ labels.group_details=Groepdetails
     labels.job_log_details=Details taaklogboek
     labels.key_match_details=Details sleutelovereenkomst
     labels.labeltype_details=Details labeltype
    +labels.tagtype_details=Details tag
     labels.pathmap_details=Details padtoewijzing
     labels.related_content_details=Details gerelateerde inhoud
     labels.related_query_details=Details gerelateerde query
    @@ -432,6 +434,7 @@ labels.plugin_list=Pluginlijst
     labels.pathmap_list=Padtoewijzingslijst
     labels.log_file_list=Logbestandlijst
     labels.labeltype_list=Labeltypelijst
    +labels.tagtype_list=Taglijst
     labels.key_match_list=Sleutelovereenkomstlijst
     labels.job_log_list=Taakloglijst
     labels.group_list=Groeplijst
    @@ -515,6 +518,11 @@ labels.labeltype_name=Naam
     labels.labeltype_value=Waarde
     labels.labeltype_included_paths=Inbegrepen paden
     labels.labeltype_excluded_paths=Uitgesloten paden
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Naam
    +labels.tagtype_owner=Eigenaar
    +labels.tagtype_paths=Paden
     labels.roletype_configuration=Rol
     labels.roletype_title_details=Rol
     labels.roletype_name=Naam
    diff --git a/src/main/resources/fess_label_pl.properties b/src/main/resources/fess_label_pl.properties
    index 257be9e7eb..68d3db60f0 100644
    --- a/src/main/resources/fess_label_pl.properties
    +++ b/src/main/resources/fess_label_pl.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=System plików
     labels.menu_data_store=Magazyn danych
     labels.menu_label_type=Etykieta
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Dopasowanie klucza
     labels.menu_boost_document_rule=Wzmocnienie dokumentu
     labels.menu_path_mapping=Mapowanie ścieżek
    @@ -408,6 +409,7 @@ labels.group_details=Group Szczegóły
     labels.job_log_details=Job Log Szczegóły
     labels.key_match_details=Key Match Szczegóły
     labels.labeltype_details=Labeltype Szczegóły
    +labels.tagtype_details=Szczegóły tagu
     labels.pathmap_details=Pathmap Szczegóły
     labels.related_content_details=Related Content Szczegóły
     labels.related_query_details=Related Query Szczegóły
    @@ -432,6 +434,7 @@ labels.plugin_list=Lista wtyczek
     labels.pathmap_list=Lista mapowania ścieżek
     labels.log_file_list=Lista plików dziennika
     labels.labeltype_list=Lista typów etykiet
    +labels.tagtype_list=Lista tagów
     labels.key_match_list=Lista dopasowania kluczy
     labels.job_log_list=Lista dzienników zadań
     labels.group_list=Lista grup
    @@ -515,6 +518,11 @@ labels.labeltype_name=Nazwa
     labels.labeltype_value=Wartość
     labels.labeltype_included_paths=Uwzględnione ścieżki
     labels.labeltype_excluded_paths=Wykluczone ścieżki
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Nazwa
    +labels.tagtype_owner=Właściciel
    +labels.tagtype_paths=Ścieżki
     labels.roletype_configuration=Rola
     labels.roletype_title_details=Rola
     labels.roletype_name=Nazwa
    diff --git a/src/main/resources/fess_label_pt_BR.properties b/src/main/resources/fess_label_pt_BR.properties
    index 9936b31d7a..036e826225 100644
    --- a/src/main/resources/fess_label_pt_BR.properties
    +++ b/src/main/resources/fess_label_pt_BR.properties
    @@ -148,6 +148,7 @@ labels.menu_web=Web
     labels.menu_file_system=Sistema de arquivos
     labels.menu_data_store=Armazenamento de dados
     labels.menu_label_type=Rótulo
    +labels.menu_tag_type=Tag
     labels.menu_key_match=Correspondência de chave
     labels.menu_boost_document_rule=Regra de impulso de documento
     labels.menu_path_mapping=Mapeamento de caminho
    @@ -407,6 +408,7 @@ labels.group_details=Group Detalhes
     labels.job_log_details=Job Log Detalhes
     labels.key_match_details=Key Match Detalhes
     labels.labeltype_details=Labeltype Detalhes
    +labels.tagtype_details=Detalhes da tag
     labels.pathmap_details=Pathmap Detalhes
     labels.related_content_details=Related Content Detalhes
     labels.related_query_details=Related Query Detalhes
    @@ -431,6 +433,7 @@ labels.plugin_list=Lista de plugins
     labels.pathmap_list=Lista de mapeamento de caminhos
     labels.log_file_list=Lista de arquivos de log
     labels.labeltype_list=Lista de tipos de rótulo
    +labels.tagtype_list=Lista de tags
     labels.key_match_list=Lista de correspondência de chaves
     labels.job_log_list=Lista de logs de trabalho
     labels.group_list=Lista de grupos
    @@ -514,6 +517,11 @@ labels.labeltype_name=Nome
     labels.labeltype_value=Valor
     labels.labeltype_included_paths=Caminhos incluídos
     labels.labeltype_excluded_paths=Caminhos excluídos
    +labels.tagtype_configuration=Tag
    +labels.tagtype_title_details=Tag
    +labels.tagtype_name=Nome
    +labels.tagtype_owner=Proprietário
    +labels.tagtype_paths=Caminhos
     labels.roletype_configuration=Função
     labels.roletype_title_details=Função
     labels.roletype_name=Nome
    diff --git a/src/main/resources/fess_label_ru.properties b/src/main/resources/fess_label_ru.properties
    index 1b7e72af1f..2ba1c8bd25 100644
    --- a/src/main/resources/fess_label_ru.properties
    +++ b/src/main/resources/fess_label_ru.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Веб
     labels.menu_file_system=Файловая система
     labels.menu_data_store=Хранилище данных
     labels.menu_label_type=Метка
    +labels.menu_tag_type=Тег
     labels.menu_key_match=Совпадение ключей
     labels.menu_boost_document_rule=Ускорение документов
     labels.menu_path_mapping=Сопоставление путей
    @@ -408,6 +409,7 @@ labels.group_details=Group Детали
     labels.job_log_details=Job Log Детали
     labels.key_match_details=Key Match Детали
     labels.labeltype_details=Labeltype Детали
    +labels.tagtype_details=Сведения о теге
     labels.pathmap_details=Pathmap Детали
     labels.related_content_details=Related Content Детали
     labels.related_query_details=Related Query Детали
    @@ -432,6 +434,7 @@ labels.plugin_list=Список плагинов
     labels.pathmap_list=Список сопоставления путей
     labels.log_file_list=Список файлов журнала
     labels.labeltype_list=Список типов меток
    +labels.tagtype_list=Список тегов
     labels.key_match_list=Список соответствия ключей
     labels.job_log_list=Список журналов заданий
     labels.group_list=Список групп
    @@ -515,6 +518,11 @@ labels.labeltype_name=Имя
     labels.labeltype_value=Значение
     labels.labeltype_included_paths=Включенные пути
     labels.labeltype_excluded_paths=Исключенные пути
    +labels.tagtype_configuration=Тег
    +labels.tagtype_title_details=Тег
    +labels.tagtype_name=Имя
    +labels.tagtype_owner=Владелец
    +labels.tagtype_paths=Пути
     labels.roletype_configuration=Роль
     labels.roletype_title_details=Роль
     labels.roletype_name=Имя
    diff --git a/src/main/resources/fess_label_tr.properties b/src/main/resources/fess_label_tr.properties
    index 2c520aa1b6..ecfcae8543 100644
    --- a/src/main/resources/fess_label_tr.properties
    +++ b/src/main/resources/fess_label_tr.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=Dosya Sistemi
     labels.menu_data_store=Veri Deposu
     labels.menu_label_type=Etiket
    +labels.menu_tag_type=Kullanıcı etiketi
     labels.menu_key_match=Anahtar Eşleşmesi
     labels.menu_boost_document_rule=Belge Artırma
     labels.menu_path_mapping=Yol Eşleştirme
    @@ -408,6 +409,7 @@ labels.group_details=Group Detaylar
     labels.job_log_details=Job Log Detaylar
     labels.key_match_details=Key Match Detaylar
     labels.labeltype_details=Labeltype Detaylar
    +labels.tagtype_details=Kullanıcı etiketi detayları
     labels.pathmap_details=Pathmap Detaylar
     labels.related_content_details=Related Content Detaylar
     labels.related_query_details=Related Query Detaylar
    @@ -432,6 +434,7 @@ labels.plugin_list=Eklenti listesi
     labels.pathmap_list=Yol eşleme listesi
     labels.log_file_list=Günlük dosyası listesi
     labels.labeltype_list=Etiket türü listesi
    +labels.tagtype_list=Kullanıcı etiketi listesi
     labels.key_match_list=Anahtar eşleşme listesi
     labels.job_log_list=İş günlüğü listesi
     labels.group_list=Grup listesi
    @@ -515,6 +518,11 @@ labels.labeltype_name=Ad
     labels.labeltype_value=Değer
     labels.labeltype_included_paths=Dahil Edilen Yollar
     labels.labeltype_excluded_paths=Hariç Tutulan Yollar
    +labels.tagtype_configuration=Kullanıcı etiketi
    +labels.tagtype_title_details=Kullanıcı etiketi
    +labels.tagtype_name=Ad
    +labels.tagtype_owner=Sahip
    +labels.tagtype_paths=Yollar
     labels.roletype_configuration=Rol
     labels.roletype_title_details=Rol
     labels.roletype_name=Ad
    diff --git a/src/main/resources/fess_label_zh_CN.properties b/src/main/resources/fess_label_zh_CN.properties
    index 3ddebe355a..0875f73845 100644
    --- a/src/main/resources/fess_label_zh_CN.properties
    +++ b/src/main/resources/fess_label_zh_CN.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=文件系统
     labels.menu_data_store=数据存储
     labels.menu_label_type=标签
    +labels.menu_tag_type=用户标签
     labels.menu_key_match=关键词匹配
     labels.menu_boost_document_rule=文档提升
     labels.menu_path_mapping=路径映射
    @@ -408,6 +409,7 @@ labels.group_details=Group 详情
     labels.job_log_details=Job Log 详情
     labels.key_match_details=Key Match 详情
     labels.labeltype_details=Labeltype 详情
    +labels.tagtype_details=用户标签详情
     labels.pathmap_details=Pathmap 详情
     labels.related_content_details=Related Content 详情
     labels.related_query_details=Related Query 详情
    @@ -432,6 +434,7 @@ labels.plugin_list=插件列表
     labels.pathmap_list=路径映射列表
     labels.log_file_list=日志文件列表
     labels.labeltype_list=标签类型列表
    +labels.tagtype_list=用户标签列表
     labels.key_match_list=关键字匹配列表
     labels.job_log_list=作业日志列表
     labels.group_list=组列表
    @@ -515,6 +518,11 @@ labels.labeltype_name=名称
     labels.labeltype_value=值
     labels.labeltype_included_paths=包含的路径
     labels.labeltype_excluded_paths=排除的路径
    +labels.tagtype_configuration=用户标签
    +labels.tagtype_title_details=用户标签
    +labels.tagtype_name=名称
    +labels.tagtype_owner=所有者
    +labels.tagtype_paths=路径
     labels.roletype_configuration=角色
     labels.roletype_title_details=角色
     labels.roletype_name=名称
    diff --git a/src/main/resources/fess_label_zh_TW.properties b/src/main/resources/fess_label_zh_TW.properties
    index 7396a49941..f820136eba 100644
    --- a/src/main/resources/fess_label_zh_TW.properties
    +++ b/src/main/resources/fess_label_zh_TW.properties
    @@ -149,6 +149,7 @@ labels.menu_web=Web
     labels.menu_file_system=檔案系統
     labels.menu_data_store=資料儲存
     labels.menu_label_type=標籤
    +labels.menu_tag_type=使用者標籤
     labels.menu_key_match=關鍵字匹配
     labels.menu_boost_document_rule=文檔提升
     labels.menu_path_mapping=路徑映射
    @@ -408,6 +409,7 @@ labels.group_details=Group 詳情
     labels.job_log_details=Job Log 詳情
     labels.key_match_details=Key Match 詳情
     labels.labeltype_details=Labeltype 詳情
    +labels.tagtype_details=使用者標籤詳情
     labels.pathmap_details=Pathmap 詳情
     labels.related_content_details=Related Content 詳情
     labels.related_query_details=Related Query 詳情
    @@ -432,6 +434,7 @@ labels.plugin_list=外掛程式清單
     labels.pathmap_list=路徑映射清單
     labels.log_file_list=日誌檔案清單
     labels.labeltype_list=標籤類型清單
    +labels.tagtype_list=使用者標籤清單
     labels.key_match_list=關鍵字配對清單
     labels.job_log_list=作業日誌清單
     labels.group_list=群組清單
    @@ -515,6 +518,11 @@ labels.labeltype_name=名稱
     labels.labeltype_value=值
     labels.labeltype_included_paths=包含的路徑
     labels.labeltype_excluded_paths=排除的路徑
    +labels.tagtype_configuration=使用者標籤
    +labels.tagtype_title_details=使用者標籤
    +labels.tagtype_name=名稱
    +labels.tagtype_owner=擁有者
    +labels.tagtype_paths=路徑
     labels.roletype_configuration=角色
     labels.roletype_title_details=角色
     labels.roletype_name=名稱
    diff --git a/src/main/resources/fess_message.properties b/src/main/resources/fess_message.properties
    index c6023589ff..015507128c 100644
    --- a/src/main/resources/fess_message.properties
    +++ b/src/main/resources/fess_message.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Failed to generate related queries: {0}
     errors.related_query_generation_disabled=Related queries cannot be generated because the search log or user info is disabled.
     errors.related_query_generation_in_progress=Related queries are already being generated.
    +errors.tagtype_invalid_name=Enter a tag name of 1 to {0} characters without control characters.
    +errors.tagtype_too_many_paths=A tag can have up to {0} paths.
    +errors.tagtype_already_exists=A tag with the same name and owner already exists.
    +errors.tagtype_changed_concurrently=The tag was changed by someone else. Reload it and try again.
     
     errors.property_required={0} is required.
     errors.property_type_integer={0} must be an integer.
    diff --git a/src/main/resources/fess_message_de.properties b/src/main/resources/fess_message_de.properties
    index 23945ed3c6..67eb425513 100644
    --- a/src/main/resources/fess_message_de.properties
    +++ b/src/main/resources/fess_message_de.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Fehler beim Generieren verwandter Abfragen: {0}
     errors.related_query_generation_disabled=Verwandte Abfragen können nicht generiert werden, da das Suchprotokoll oder die Benutzerinformationen deaktiviert sind.
     errors.related_query_generation_in_progress=Verwandte Abfragen werden bereits generiert.
    +errors.tagtype_invalid_name=Geben Sie einen Tag-Namen mit 1 bis {0} Zeichen ohne Steuerzeichen ein.
    +errors.tagtype_too_many_paths=Ein Tag kann bis zu {0} Pfade haben.
    +errors.tagtype_already_exists=Ein Tag mit demselben Namen und Besitzer ist bereits vorhanden.
    +errors.tagtype_changed_concurrently=Der Tag wurde von einer anderen Person geändert. Laden Sie ihn neu und versuchen Sie es erneut.
     
     errors.property_required={0} ist erforderlich.
     errors.property_type_integer={0} muss eine ganze Zahl sein.
    diff --git a/src/main/resources/fess_message_en.properties b/src/main/resources/fess_message_en.properties
    index c6023589ff..015507128c 100644
    --- a/src/main/resources/fess_message_en.properties
    +++ b/src/main/resources/fess_message_en.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Failed to generate related queries: {0}
     errors.related_query_generation_disabled=Related queries cannot be generated because the search log or user info is disabled.
     errors.related_query_generation_in_progress=Related queries are already being generated.
    +errors.tagtype_invalid_name=Enter a tag name of 1 to {0} characters without control characters.
    +errors.tagtype_too_many_paths=A tag can have up to {0} paths.
    +errors.tagtype_already_exists=A tag with the same name and owner already exists.
    +errors.tagtype_changed_concurrently=The tag was changed by someone else. Reload it and try again.
     
     errors.property_required={0} is required.
     errors.property_type_integer={0} must be an integer.
    diff --git a/src/main/resources/fess_message_es.properties b/src/main/resources/fess_message_es.properties
    index 8fa8fff3bb..53af424eab 100644
    --- a/src/main/resources/fess_message_es.properties
    +++ b/src/main/resources/fess_message_es.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=No se pudieron generar las consultas relacionadas: {0}
     errors.related_query_generation_disabled=No se pueden generar consultas relacionadas porque el registro de búsqueda o la información de usuario están deshabilitados.
     errors.related_query_generation_in_progress=Ya se están generando consultas relacionadas.
    +errors.tagtype_invalid_name=Introduzca un nombre de etiqueta de 1 a {0} caracteres sin caracteres de control.
    +errors.tagtype_too_many_paths=Una etiqueta de usuario puede tener hasta {0} rutas.
    +errors.tagtype_already_exists=Ya existe una etiqueta de usuario con el mismo nombre y propietario.
    +errors.tagtype_changed_concurrently=Otra persona ha modificado la etiqueta de usuario. Vuelva a cargarla e inténtelo de nuevo.
     
     errors.property_required={0} es obligatorio.
     errors.property_type_integer={0} es un número.
    diff --git a/src/main/resources/fess_message_fr.properties b/src/main/resources/fess_message_fr.properties
    index 03696130b6..b87185fc89 100644
    --- a/src/main/resources/fess_message_fr.properties
    +++ b/src/main/resources/fess_message_fr.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Échec de la génération des requêtes associées : {0}
     errors.related_query_generation_disabled=Impossible de générer des requêtes associées car le journal de recherche ou les informations utilisateur sont désactivés.
     errors.related_query_generation_in_progress=La génération des requêtes associées est déjà en cours.
    +errors.tagtype_invalid_name=Saisissez un nom de tag de 1 à {0} caractères sans caractères de contrôle.
    +errors.tagtype_too_many_paths=Un tag peut avoir au maximum {0} chemins.
    +errors.tagtype_already_exists=Un tag portant le même nom et le même propriétaire existe déjà.
    +errors.tagtype_changed_concurrently=Le tag a été modifié par quelqu'un d'autre. Rechargez-le et réessayez.
     
     errors.property_required={0} est requis.
     errors.property_type_integer={0} doit être un entier.
    diff --git a/src/main/resources/fess_message_hi.properties b/src/main/resources/fess_message_hi.properties
    index 955f5ea8cc..5e69e2abbd 100644
    --- a/src/main/resources/fess_message_hi.properties
    +++ b/src/main/resources/fess_message_hi.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=संबंधित क्वेरी जनरेट करने में विफल: {0}
     errors.related_query_generation_disabled=खोज लॉग या उपयोगकर्ता जानकारी अक्षम होने के कारण संबंधित क्वेरी जनरेट नहीं की जा सकतीं।
     errors.related_query_generation_in_progress=संबंधित क्वेरी पहले से जनरेट की जा रही हैं।
    +errors.tagtype_invalid_name=नियंत्रण वर्णों के बिना 1 से {0} वर्णों का टैग नाम दर्ज करें।
    +errors.tagtype_too_many_paths=एक टैग में अधिकतम {0} पथ हो सकते हैं।
    +errors.tagtype_already_exists=समान नाम और स्वामी वाला टैग पहले से मौजूद है।
    +errors.tagtype_changed_concurrently=टैग को किसी और ने बदल दिया है। इसे फिर से लोड करें और पुनः प्रयास करें।
     
     errors.property_required={0} आवश्यक है।
     errors.property_type_integer={0} एक पूर्णांक होना चाहिए।
    diff --git a/src/main/resources/fess_message_id.properties b/src/main/resources/fess_message_id.properties
    index b32b6a2a34..7c9c3f57af 100644
    --- a/src/main/resources/fess_message_id.properties
    +++ b/src/main/resources/fess_message_id.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Gagal membuat kueri terkait: {0}
     errors.related_query_generation_disabled=Kueri terkait tidak dapat dibuat karena log pencarian atau info pengguna dinonaktifkan.
     errors.related_query_generation_in_progress=Kueri terkait sedang dibuat.
    +errors.tagtype_invalid_name=Masukkan nama tag sepanjang 1 hingga {0} karakter tanpa karakter kontrol.
    +errors.tagtype_too_many_paths=Sebuah tag dapat memiliki hingga {0} jalur.
    +errors.tagtype_already_exists=Tag dengan nama dan pemilik yang sama sudah ada.
    +errors.tagtype_changed_concurrently=Tag telah diubah oleh orang lain. Muat ulang lalu coba lagi.
     
     errors.property_required={0} wajib diisi.
     errors.property_type_integer={0} harus berupa integer.
    diff --git a/src/main/resources/fess_message_it.properties b/src/main/resources/fess_message_it.properties
    index 6a9dc92488..195a0e98d8 100644
    --- a/src/main/resources/fess_message_it.properties
    +++ b/src/main/resources/fess_message_it.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Impossibile generare le query correlate: {0}
     errors.related_query_generation_disabled=Impossibile generare query correlate perché il log di ricerca o le informazioni utente sono disabilitati.
     errors.related_query_generation_in_progress=La generazione delle query correlate è già in corso.
    +errors.tagtype_invalid_name=Inserire un nome di tag da 1 a {0} caratteri senza caratteri di controllo.
    +errors.tagtype_too_many_paths=Un tag può avere fino a {0} percorsi.
    +errors.tagtype_already_exists=Esiste già un tag con lo stesso nome e proprietario.
    +errors.tagtype_changed_concurrently=Il tag è stato modificato da un altro utente. Ricaricarlo e riprovare.
     
     errors.property_required={0} è obbligatorio.
     errors.property_type_integer={0} è un numero intero.
    diff --git a/src/main/resources/fess_message_ja.properties b/src/main/resources/fess_message_ja.properties
    index c1db0af3e9..a6b09bc440 100644
    --- a/src/main/resources/fess_message_ja.properties
    +++ b/src/main/resources/fess_message_ja.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=このテーマはこのサーバーよ
     errors.failed_to_generate_related_query=関連クエリーの生成に失敗しました: {0}
     errors.related_query_generation_disabled=検索ログまたはユーザー情報が無効なため、関連クエリーを生成できません。
     errors.related_query_generation_in_progress=関連クエリーの生成は既に実行中です。
    +errors.tagtype_invalid_name=タグ名は制御文字を含めずに1~{0}文字で入力してください。
    +errors.tagtype_too_many_paths=1つのタグに設定できるパスは{0}件までです。
    +errors.tagtype_already_exists=同じ名前と所有者のタグがすでに存在します。
    +errors.tagtype_changed_concurrently=タグが他のユーザーによって変更されました。再読み込みしてからやり直してください。
     
     errors.property_required={0}は必須です。
     errors.property_type_integer={0}は数値でなければなりません。
    diff --git a/src/main/resources/fess_message_ko.properties b/src/main/resources/fess_message_ko.properties
    index 4202f6757f..90701be0a7 100644
    --- a/src/main/resources/fess_message_ko.properties
    +++ b/src/main/resources/fess_message_ko.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=관련 쿼리를 생성하지 못했습니다: {0}
     errors.related_query_generation_disabled=검색 로그 또는 사용자 정보가 비활성화되어 있어 관련 쿼리를 생성할 수 없습니다.
     errors.related_query_generation_in_progress=관련 쿼리를 이미 생성하고 있습니다.
    +errors.tagtype_invalid_name=제어 문자 없이 1~{0}자의 태그 이름을 입력하십시오.
    +errors.tagtype_too_many_paths=하나의 태그에는 최대 {0}개의 경로를 지정할 수 있습니다.
    +errors.tagtype_already_exists=같은 이름과 소유자의 태그가 이미 존재합니다.
    +errors.tagtype_changed_concurrently=다른 사용자가 태그를 변경했습니다. 다시 불러온 후 다시 시도하십시오.
     
     errors.property_required={0}은(는) 필수입니다.
     errors.property_type_integer={0}은(는) 정수여야 합니다.
    diff --git a/src/main/resources/fess_message_nl.properties b/src/main/resources/fess_message_nl.properties
    index af2941789c..14b8230252 100644
    --- a/src/main/resources/fess_message_nl.properties
    +++ b/src/main/resources/fess_message_nl.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Genereren van gerelateerde zoekopdrachten mislukt: {0}
     errors.related_query_generation_disabled=Gerelateerde zoekopdrachten kunnen niet worden gegenereerd omdat het zoeklogboek of de gebruikersinformatie is uitgeschakeld.
     errors.related_query_generation_in_progress=Gerelateerde zoekopdrachten worden al gegenereerd.
    +errors.tagtype_invalid_name=Voer een tagnaam van 1 tot {0} tekens in zonder besturingstekens.
    +errors.tagtype_too_many_paths=Een tag kan maximaal {0} paden hebben.
    +errors.tagtype_already_exists=Er bestaat al een tag met dezelfde naam en eigenaar.
    +errors.tagtype_changed_concurrently=De tag is door iemand anders gewijzigd. Laad de tag opnieuw en probeer het nogmaals.
     
     errors.property_required={0} is vereist.
     errors.property_type_integer={0} is een geheel getal.
    diff --git a/src/main/resources/fess_message_pl.properties b/src/main/resources/fess_message_pl.properties
    index bc0608f4b0..8127c3f879 100644
    --- a/src/main/resources/fess_message_pl.properties
    +++ b/src/main/resources/fess_message_pl.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Nie udało się wygenerować powiązanych zapytań: {0}
     errors.related_query_generation_disabled=Nie można wygenerować powiązanych zapytań, ponieważ dziennik wyszukiwania lub informacje o użytkowniku są wyłączone.
     errors.related_query_generation_in_progress=Powiązane zapytania są już generowane.
    +errors.tagtype_invalid_name=Wprowadź nazwę tagu o długości od 1 do {0} znaków bez znaków sterujących.
    +errors.tagtype_too_many_paths=Tag może mieć maksymalnie {0} ścieżek.
    +errors.tagtype_already_exists=Tag o tej samej nazwie i tym samym właścicielu już istnieje.
    +errors.tagtype_changed_concurrently=Tag został zmieniony przez kogoś innego. Załaduj go ponownie i spróbuj jeszcze raz.
     
     errors.property_required={0} jest wymagane.
     errors.property_type_integer={0} jest liczbą całkowitą.
    diff --git a/src/main/resources/fess_message_pt_BR.properties b/src/main/resources/fess_message_pt_BR.properties
    index bf8844b7f8..3ac28cdaa9 100644
    --- a/src/main/resources/fess_message_pt_BR.properties
    +++ b/src/main/resources/fess_message_pt_BR.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Falha ao gerar consultas relacionadas: {0}
     errors.related_query_generation_disabled=Não é possível gerar consultas relacionadas porque o log de pesquisa ou as informações do usuário estão desativados.
     errors.related_query_generation_in_progress=As consultas relacionadas já estão sendo geradas.
    +errors.tagtype_invalid_name=Digite um nome de tag de 1 a {0} caracteres sem caracteres de controle.
    +errors.tagtype_too_many_paths=Uma tag pode ter até {0} caminhos.
    +errors.tagtype_already_exists=Já existe uma tag com o mesmo nome e proprietário.
    +errors.tagtype_changed_concurrently=A tag foi alterada por outra pessoa. Recarregue-a e tente novamente.
     
     errors.property_required={0} é obrigatório.
     errors.property_type_integer={0} é um número.
    diff --git a/src/main/resources/fess_message_ru.properties b/src/main/resources/fess_message_ru.properties
    index 44db0cf7fc..cbc3d33fc0 100644
    --- a/src/main/resources/fess_message_ru.properties
    +++ b/src/main/resources/fess_message_ru.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=Не удалось создать связанные запросы: {0}
     errors.related_query_generation_disabled=Невозможно создать связанные запросы, так как журнал поиска или сведения о пользователях отключены.
     errors.related_query_generation_in_progress=Связанные запросы уже создаются.
    +errors.tagtype_invalid_name=Введите имя тега длиной от 1 до {0} символов без управляющих символов.
    +errors.tagtype_too_many_paths=Тег может содержать не более {0} путей.
    +errors.tagtype_already_exists=Тег с таким же именем и владельцем уже существует.
    +errors.tagtype_changed_concurrently=Тег был изменен другим пользователем. Перезагрузите его и повторите попытку.
     
     errors.property_required={0} обязательно.
     errors.property_type_integer={0} должно быть целым числом.
    diff --git a/src/main/resources/fess_message_tr.properties b/src/main/resources/fess_message_tr.properties
    index e6eb264060..25455a100b 100644
    --- a/src/main/resources/fess_message_tr.properties
    +++ b/src/main/resources/fess_message_tr.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=İlgili sorgular oluşturulamadı: {0}
     errors.related_query_generation_disabled=Arama günlüğü veya kullanıcı bilgisi devre dışı olduğundan ilgili sorgular oluşturulamıyor.
     errors.related_query_generation_in_progress=İlgili sorgular zaten oluşturuluyor.
    +errors.tagtype_invalid_name=Kontrol karakteri içermeyen, 1 ile {0} karakter arasında bir etiket adı girin.
    +errors.tagtype_too_many_paths=Bir kullanıcı etiketi en fazla {0} yola sahip olabilir.
    +errors.tagtype_already_exists=Aynı ada ve sahibe sahip bir kullanıcı etiketi zaten var.
    +errors.tagtype_changed_concurrently=Kullanıcı etiketi başka biri tarafından değiştirildi. Yeniden yükleyip tekrar deneyin.
     
     errors.property_required={0} gereklidir.
     errors.property_type_integer={0} bir tamsayı olmalıdır.
    diff --git a/src/main/resources/fess_message_zh_CN.properties b/src/main/resources/fess_message_zh_CN.properties
    index 2d7212bde2..d28b82d20a 100644
    --- a/src/main/resources/fess_message_zh_CN.properties
    +++ b/src/main/resources/fess_message_zh_CN.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=生成相关查询失败:{0}
     errors.related_query_generation_disabled=由于搜索日志或用户信息已禁用,无法生成相关查询。
     errors.related_query_generation_in_progress=相关查询正在生成中。
    +errors.tagtype_invalid_name=请输入1到{0}个字符且不含控制字符的标签名称。
    +errors.tagtype_too_many_paths=一个用户标签最多可以有{0}个路径。
    +errors.tagtype_already_exists=已存在名称和所有者相同的用户标签。
    +errors.tagtype_changed_concurrently=用户标签已被其他人更改。请重新加载后再试。
     
     errors.property_required={0} 是必需的。
     errors.property_type_integer={0} 是数字。
    diff --git a/src/main/resources/fess_message_zh_TW.properties b/src/main/resources/fess_message_zh_TW.properties
    index 0db389a9a9..90e121c2fd 100644
    --- a/src/main/resources/fess_message_zh_TW.properties
    +++ b/src/main/resources/fess_message_zh_TW.properties
    @@ -181,6 +181,10 @@ errors.theme_incompatible_fess_version=This theme requires a newer version of Fe
     errors.failed_to_generate_related_query=產生相關查詢失敗:{0}
     errors.related_query_generation_disabled=由於搜尋日誌或使用者資訊已停用,無法產生相關查詢。
     errors.related_query_generation_in_progress=相關查詢正在產生中。
    +errors.tagtype_invalid_name=請輸入1到{0}個字元且不含控制字元的標籤名稱。
    +errors.tagtype_too_many_paths=一個使用者標籤最多可以有{0}個路徑。
    +errors.tagtype_already_exists=已存在名稱和擁有者相同的使用者標籤。
    +errors.tagtype_changed_concurrently=使用者標籤已被其他人變更。請重新載入後再試一次。
     
     errors.property_required={0} 是必需的。
     errors.property_type_integer={0} 是數字。
    diff --git a/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype.jsp b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype.jsp
    new file mode 100644
    index 0000000000..a77bb3fa07
    --- /dev/null
    +++ b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype.jsp
    @@ -0,0 +1,170 @@
    +<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%>
    +${fe:html(true)}
    +
    +    
    +    <la:message key="labels.admin_brand_title"/> | <la:message
    +            key="labels.tagtype_configuration"/>
    +    
    +
    +
    +
    + + + + + +
    +
    +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + <%-- Message --%> +
    + +
    ${msg}
    +
    + +
    + +
    show" id="listSearchForm"> + +
    + +
    + +
    +
    +
    + +
    + +
    +
    +
    +
    + + +
    +
    +
    +
    + <%-- List --%> + +
    + +
    +
    +
    + +
    +
    + "> + + + + + + + + + + + + + + +
    ${f:h(data.name)}${f:h(data.owner)}
    +
    +
    +
    + <%-- Paging Info --%> +
    + +
    + <%-- Paging Navigation --%> +
    +
      + +
    • + +
    • +
      + +
    • +
      + +
    • class="page-item active"> + ${p}
    • +
      + +
    • + +
    • +
      + +
    • +
      +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + +
    + + +${fe:html(false)} diff --git a/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_details.jsp b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_details.jsp new file mode 100644 index 0000000000..fefd05dd46 --- /dev/null +++ b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_details.jsp @@ -0,0 +1,101 @@ +<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%> +${fe:html(true)} + + + <la:message key="labels.admin_brand_title"/> | <la:message + key="labels.tagtype_configuration"/> + + + +
    + + + + + +
    +
    +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +
    +
    +
    + + + + + + + +
    +
    +
    card-successcard-dangercard-primary"> + <%-- Card Header --%> +
    + +
    + <%-- Card Body --%> +
    + <%-- Message --%> +
    + +
    ${msg}
    +
    + +
    + <%-- Form Fields --%> + "> + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ${f:h(name)}
    ${f:h(owner)}
    ${f:br(f:h(paths))}
    ${f:br(f:h(permissions))}
    ${f:h(virtualHost)}
    ${f:h(sortOrder)}
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + +${fe:html(false)} diff --git a/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_edit.jsp b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_edit.jsp new file mode 100644 index 0000000000..ddac5d2e55 --- /dev/null +++ b/src/main/webapp/WEB-INF/view/admin/tagtype/admin_tagtype_edit.jsp @@ -0,0 +1,121 @@ +<%@page pageEncoding="UTF-8" contentType="text/html; charset=UTF-8"%> +${fe:html(true)} + + + <la:message key="labels.admin_brand_title"/> | <la:message + key="labels.tagtype_configuration"/> + + + +
    + + + + + +
    +
    +
    +
    +
    +

    + +

    +
    +
    + +
    +
    +
    +
    +
    + + + + + + + +
    +
    +
    card-success"> +
    + +
    +
    +
    + +
    ${msg}
    +
    + +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    + +
    +
    +
    +
    +
    +
    + +
    + + +${fe:html(false)} diff --git a/src/main/webapp/WEB-INF/view/common/admin/sidebar.jsp b/src/main/webapp/WEB-INF/view/common/admin/sidebar.jsp index f8709aa2bf..e6caae912e 100644 --- a/src/main/webapp/WEB-INF/view/common/admin/sidebar.jsp +++ b/src/main/webapp/WEB-INF/view/common/admin/sidebar.jsp @@ -112,7 +112,7 @@ - + + + +