feat(search): keep user tags per user in a tag_type index - #3551
Merged
Merged
Conversation
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.
…ookup 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.
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.
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.
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.
…re 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.
…'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.
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.
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.
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.
When an administrator changes the owner of a tag, replace the user role of the old owner in its permissions with that of the new owner, so the old owner no longer sees the tag or the documents carrying it. The sharing roles and any other role are kept. This applies to both the admin screen and the admin API.
With features.user_tag, result cards show the visible tags as chips that filter by fields.tag, and other users' tags read with a shared prefix. Logged-in users get an inline editor that adds one of their own tags by id or a tag by name and removes their own tags, with a note that search results follow after a short while. A Tags facet uses the same labels, and a My tags panel on the options bar lists the user's tags and renames, shares or unshares, and deletes them after a confirmation in the panel. 401 and 403 answers open the login modal. api.js gains put() and del(). Adds the tag messages to all bundles and bumps the theme version to 1.5.0.
A hit can carry the tag values of any number of users, and every one of them was looked up by id to decorate the v2 search response. The values of the hits and of the tag facet are now first intersected with the tags the caller can see (capped by user.tag.visible.max.size), and only that intersection is looked up. The visible tag values are cached in the request, so the tag facet and the hit decoration share one computation. Also: - applyTags logs a failed tag lookup at ERROR, since the batch is then indexed without its tags until tag_updater runs. - TagTypeHelper no longer handles the admin search: every caller skips the tag restriction for it, so the branches were unreachable. The javadoc of the helper and its callers states this.
This was referenced Oct 4, 2026
marevol
added a commit
to codelibs/fess-docs
that referenced
this pull request
Oct 4, 2026
This follows codelibs/fess#3551, which replaces the user tags that were labels of the kind "tag" with per-user tags in the fess_config.tag_type index, and replaces the tag documentation added in #565. - api/api-tag.rst: the new endpoints (GET/POST /api/v2/tags, PUT/DELETE /api/v2/tags/{id}, GET/POST /api/v2/documents/{docId}/tags and DELETE /api/v2/documents/{docId}/tags/{id}) with their login and CSRF requirements, examples and errors; private and shared tags; the tags of search hits, the tag facet and fields.tag filtering; the delay until the per-minute log_aggregator job applies the queued changes; and the user.tag.* settings. - admin/tagtype-guide.rst: a new page for the admin Tag screen, its fields, sharing, how changes reach documents through the queue and the tag_updater job, and notes for operators. It is listed after the label page in the administrator guide. - admin/labeltype-guide.rst: back to its content before #565, without the Kind field and the user tag section. - api/api-uiconfig.rst: features.user_tag describes the new feature. - api/admin/api-admin-tagtype.rst: a new page for /api/admin/tagtype, listed after the label type API. Updates send seq_no and primary_term from GET setting/{id}; a stale pair or a name the owner already uses fails with a validation error. - config/properties.rst and properties.po: only the rows the pull request changes are applied (user.tag.*, index.field.tag, page.tagtype.max.fetch.size, online.help.name.tagtype and the default of index.export.exclude.fields), rendered with the generator's own functions so the pages still pass --check.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the user tags of #3536/#3540 (labels of the kind
tag) with per-user tags kept in their ownfess_config.tag_typeindex. Tags are no longer a kind of label:label_typegoes back to its state before #3536.role.search.guest.permissions), and every logged-in user can then see it and filter by it. Only the owner (or an administrator) can change it. The bootstrap theme shows other users' tags with a "Shared:" prefix.tagkeyword field asbase64url(name):base64url(owner).Changes
QueryContext.searchRequestTypeis kept because fix(search): split the vector query as the running search request type #3546 uses it.fess_config.tag_typeindex:name,owner,paths,permissions,virtualHost,sortOrderand the created/updated fields._idis the SHA-256 of the tag value.op_type=createandif_seq_no/if_primary_term.fess_configalias only, so it is not in the basic-config download.tagon the fess index. It is added to thedoc.jsonmappings. On an existing index without it, the webapp addstag: keywordat startup.log_aggregatorjob applies them to documents in bulk.content_*/title_*survive.tag_updater, rebuilds thetagfield of every document fromtag_type.IndexingHelper.sendDocumentssetstagfromtag_type.pathsfor each batch, so tags survive a re-crawl.tagis a system-managed field on the admin edit form.tag(fields.tag,tag:,ex_q,facet.query) match only the tags the caller can see. Wildcard, prefix, fuzzy and range conditions on it match nothing.includeinbuildFacet, so the v1/classic API plugins cannot list hidden tags either.tagfield is never returned.GET/POST /api/v2/tagsPUT/DELETE /api/v2/tags/{id}GET/POST /api/v2/documents/{docId}/tagsDELETE /api/v2/documents/{docId}/tags/{id}tags: [{value, name, owner, mine, shared}], and tag facet buckets are labelled.ui/configreportsfeatures.user_tag.admin/tagtype) and admin API (/api/admin/tagtype), with labels and messages in all bundles.user.tag.enabled(defaultfalse)user.tag.name.max.length,user.tag.max.tags,user.tag.max.pathsuser.tag.queue.max.size,user.tag.process.batch.size,user.tag.visible.max.sizeindex.field.tagtagis added toindex.export.exclude.fields.Notes for operators
log_aggregatorrun.log_aggregatormust keep running on every node (target=all).user.tag.enabled=falseclearstag. Runtag_updaterto rebuild in both cases.Testing
mvn test, 8,455 tests, all passing.tag_updaterrebuilds tags after a restart.