-
Notifications
You must be signed in to change notification settings - Fork 482
fix(content-drive): truncate long-text field values in listing rows (#37185) #37396
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
49228fc
c28668b
12c500d
743b2ef
07b5178
7108204
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| package com.dotmarketing.portlets.contentlet.transform.strategy; | ||
|
|
||
| import com.dotcms.api.APIProvider; | ||
| import com.dotcms.contenttype.model.field.Field; | ||
| import com.dotcms.contenttype.model.field.StoryBlockField; | ||
| import com.dotcms.contenttype.model.field.TextAreaField; | ||
| import com.dotcms.contenttype.model.field.WysiwygField; | ||
| import com.dotcms.contenttype.model.type.ContentType; | ||
| import com.dotcms.repackage.org.jsoup.Jsoup; | ||
| import com.dotmarketing.exception.DotDataException; | ||
| import com.dotmarketing.exception.DotSecurityException; | ||
| import com.dotmarketing.portlets.contentlet.model.Contentlet; | ||
| import static com.dotmarketing.portlets.contentlet.model.Contentlet.TITTLE_KEY; | ||
| import com.dotmarketing.util.Logger; | ||
| import com.dotmarketing.util.UtilMethods; | ||
| import com.liferay.portal.model.User; | ||
| import com.liferay.util.StringPool; | ||
| import io.vavr.control.Try; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import java.util.function.Function; | ||
|
|
||
| /** | ||
| * Replaces WYSIWYG, TextArea and Story Block field values in a transformed map with a | ||
| * <=150-character extracted plain-text preview, instead of the raw stored value. | ||
| * <p> | ||
| * WYSIWYG/TextArea values store raw HTML; the preview is {@code Jsoup.parse(html).text()}, | ||
| * truncated to 150 characters. Story Block values are, by the time this strategy runs, already | ||
| * the {@link Map} (or raw-string/{@code null} fallback) that {@link StoryBlockViewStrategy} | ||
| * produces -- this strategy walks that structure's {@code content} arrays recursively, collecting | ||
| * every {@code text} leaf value, and truncates the concatenation to 150 characters. This is why | ||
| * {@link TransformOptions#LONG_TEXT_PREVIEW} must be declared after {@code STORY_BLOCK_VIEW} and | ||
| * {@code JSON_VIEW} in the enum -- {@code EnumSet} iteration order runs this strategy last. | ||
| * | ||
| * @since 25.xx | ||
| */ | ||
| public class LongTextPreviewStrategy extends AbstractTransformStrategy<Contentlet> { | ||
|
|
||
| static final int MAX_PREVIEW_LENGTH = 150; | ||
|
|
||
| LongTextPreviewStrategy(final APIProvider toolBox) { | ||
| super(toolBox); | ||
| } | ||
|
|
||
| @Override | ||
| protected Map<String, Object> transform(final Contentlet source, final Map<String, Object> map, | ||
| final Set<TransformOptions> options, final User user) | ||
| throws DotDataException, DotSecurityException { | ||
| final ContentType contentType = source.getContentType(); | ||
| if (null == contentType || UtilMethods.isNotSet(contentType.id())) { | ||
| throw new DotDataException( | ||
| String.format("Content Type in Contentlet '%s' is not set", source.getIdentifier())); | ||
| } | ||
|
|
||
| applyPreview(contentType.fields(WysiwygField.class), map, LongTextPreviewStrategy::extractHtmlPreview); | ||
| applyPreview(contentType.fields(TextAreaField.class), map, LongTextPreviewStrategy::extractHtmlPreview); | ||
| applyPreview(contentType.fields(StoryBlockField.class), map, LongTextPreviewStrategy::extractStoryBlockPreview); | ||
|
|
||
| return map; | ||
| } | ||
|
|
||
| private void applyPreview(final List<Field> fields, final Map<String, Object> map, | ||
| final Function<Object, String> extractor) { | ||
| if (!UtilMethods.isSet(fields)) { | ||
| return; | ||
| } | ||
| fields.stream() | ||
| // AC-008: the "title" key is independently populated by COMMON_PROPS from | ||
| // Contentlet#getTitle() -- never overwrite it with a truncated preview, even when | ||
| // the content type's title-source field is itself WYSIWYG/TextArea/Story Block. | ||
| .filter(field -> !TITTLE_KEY.equals(field.variable())) | ||
| .forEach(field -> Try.run(() -> | ||
| map.put(field.variable(), extractor.apply(map.get(field.variable())))) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This puts a key for every in-scope field on the content type, whether or not the row had one. When the field has no value, That is a response-shape change (a consumer distinguishing "absent" from "empty" sees different data), and it adds bytes in exactly the payload this issue exists to shrink. A .filter(field -> !TITTLE_KEY.equals(field.variable()))
.filter(field -> map.containsKey(field.variable()))Worth noting the tests don't pin this either way: |
||
| .onFailure(e -> Logger.warn(LongTextPreviewStrategy.class, String.format( | ||
| "An error occurred extracting a long-text preview for field '%s' [%s]: %s", | ||
| field.variable(), field.id(), e.getMessage())))); | ||
| } | ||
|
|
||
| /** WYSIWYG/TextArea: strip HTML via Jsoup, then truncate the plain text. */ | ||
| private static String extractHtmlPreview(final Object rawValue) { | ||
| if (!(rawValue instanceof String) || ((String) rawValue).isEmpty()) { | ||
| return rawValue instanceof String ? (String) rawValue : StringPool.BLANK; | ||
| } | ||
| return truncate(Jsoup.parse((String) rawValue).text()); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The asymmetry is what stands out: Jsoup is lenient about truncated markup, so parsing a bounded prefix is enough for a 150-character preview: final String html = (String) rawValue;
final String bounded = html.length() > HTML_PARSE_BUDGET
? html.substring(0, HTML_PARSE_BUDGET) : html;
return truncate(Jsoup.parse(bounded).text());A few KB of budget covers 150 characters of visible text with wide margin even for markup-heavy bodies. Since this is a performance PR, trading wire bytes for per-request CPU seems worth at least measuring before it ships. |
||
| } | ||
|
|
||
| /** | ||
| * Story Block: the map already holds {@link StoryBlockViewStrategy}'s output -- a | ||
| * {@link Map} (parsed JSON), a raw {@link String} (non-JSON fallback) or {@code null} | ||
| * (parse-failure fallback). Extract and truncate text from whichever shape it is. | ||
| */ | ||
| private static String extractStoryBlockPreview(final Object storyBlockValue) { | ||
| if (null == storyBlockValue) { | ||
| return StringPool.BLANK; | ||
| } | ||
| if (storyBlockValue instanceof String) { | ||
| return truncate((String) storyBlockValue); | ||
| } | ||
| final StringBuilder textBuilder = new StringBuilder(); | ||
| collectText(storyBlockValue, textBuilder); | ||
| return truncate(textBuilder.toString()); | ||
| } | ||
|
|
||
| /** | ||
| * Recursively walks a Story Block JSON-tree node, collecting every {@code text} leaf value. | ||
| * Stops once enough text has been collected for the preview bound, so a large story block is | ||
| * not fully traversed/concatenated just to be truncated away afterward (found in review). | ||
| */ | ||
| private static void collectText(final Object node, final StringBuilder out) { | ||
| if (out.length() >= MAX_PREVIEW_LENGTH) { | ||
| return; | ||
| } | ||
| if (node instanceof Map) { | ||
| final Map<?, ?> nodeMap = (Map<?, ?>) node; | ||
| final Object text = nodeMap.get("text"); | ||
| if (text instanceof String) { | ||
| if (out.length() > 0) { | ||
| out.append(' '); | ||
| } | ||
| out.append((String) text); | ||
| } | ||
| final Object content = nodeMap.get("content"); | ||
| if (content instanceof List) { | ||
| for (final Object child : (List<?>) content) { | ||
| if (out.length() >= MAX_PREVIEW_LENGTH) { | ||
| break; | ||
| } | ||
| collectText(child, out); | ||
| } | ||
| } | ||
| } else if (node instanceof List) { | ||
| for (final Object child : (List<?>) node) { | ||
| if (out.length() >= MAX_PREVIEW_LENGTH) { | ||
| break; | ||
| } | ||
| collectText(child, out); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private static String truncate(final String text) { | ||
| if (null == text) { | ||
| return StringPool.BLANK; | ||
| } | ||
| if (text.length() <= MAX_PREVIEW_LENGTH) { | ||
| return text; | ||
| } | ||
| // Avoid splitting a UTF-16 surrogate pair (e.g. an emoji) at the boundary -- that would | ||
| // leave a lone high surrogate at the end of the preview (found in review). | ||
| final int cutIndex = Character.isHighSurrogate(text.charAt(MAX_PREVIEW_LENGTH - 1)) | ||
| ? MAX_PREVIEW_LENGTH - 1 : MAX_PREVIEW_LENGTH; | ||
| return text.substring(0, cutIndex); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A hard cut leaves no way to tell a truncated preview from a complete short value: a 150-character result could be the first 150 characters of a 116KB body or the whole field. The frontend can't distinguish them either — its CSS That costs real debuggability. Someone looking at a listing response, or a support engineer reading a payload, can't tell whether they are seeing the content or a projection of it — and the endpoint's own Cheapest fix that keeps the byte win: append a single Separately, the surrogate-pair guard just above is correct as far as it goes — it prevents a lone high surrogate at the boundary — but the cut still lands mid-grapheme for combining marks and ZWJ sequences (a flag emoji or a family emoji can lose half of itself). Not worth chasing unless previews are expected to carry that kind of text; |
||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LongTextPreviewStrategy.java:74 absent field keys get inserted as empty strings
Current code:
Problem: Fields absent from the map (unset value) get a new
""key inserted, changing row shape.Fix:
What to verify: a Content Type with an empty WYSIWYG/Story Block field whose key is absent from
Contentlet#getMap(); the listing row currently gainskey: "".