From 49228fc3536a01a83a86fc0b5b396ffa10d1ada3 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 3 Sep 2026 21:47:59 -0300 Subject: [PATCH 1/6] test(content-drive): add LongTextPreviewStrategy coverage (#37185) Red confirmed: LongTextPreviewStrategy/TransformOptions.LONG_TEXT_PREVIEW don't exist yet, so the test class doesn't compile. --- .../strategy/LongTextPreviewStrategyTest.java | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java diff --git a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java new file mode 100644 index 000000000000..a8f394e72772 --- /dev/null +++ b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java @@ -0,0 +1,289 @@ +package com.dotmarketing.portlets.contentlet.transform.strategy; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +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.dotmarketing.portlets.contentlet.model.Contentlet; +import java.util.EnumSet; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Unit tests for {@link LongTextPreviewStrategy} (issue #37185, FR/AC-001, AC-007) — the strategy + * that replaces WYSIWYG/TextArea/Story Block field values in a listing row's map with a + * <=150-character extracted plain-text preview. + * + *

{@code transform} is package-protected, so these tests call it directly rather than through + * reflection, mirroring {@link StoryBlockViewStrategy}'s own construction (a mocked + * {@link APIProvider} is enough — the strategy's logic never calls into the tool box).

+ */ +public class LongTextPreviewStrategyTest { + + private static final String WYSIWYG_VAR = "ltpWysiwyg"; + private static final String TEXTAREA_VAR = "ltpTextArea"; + private static final String STORY_VAR = "ltpStory"; + + private static Field mockField(final Class type, final String variable) { + final Field field = Mockito.mock(type); + Mockito.when(field.variable()).thenReturn(variable); + return field; + } + + private static ContentType mockContentType(final List wysiwygFields, + final List textAreaFields, final List storyBlockFields) { + final ContentType contentType = Mockito.mock(ContentType.class); + Mockito.when(contentType.id()).thenReturn("content-type-1"); + Mockito.when(contentType.fields(WysiwygField.class)).thenReturn(wysiwygFields); + Mockito.when(contentType.fields(TextAreaField.class)).thenReturn(textAreaFields); + Mockito.when(contentType.fields(StoryBlockField.class)).thenReturn(storyBlockFields); + return contentType; + } + + private static Contentlet mockContentlet(final ContentType contentType) { + final Contentlet contentlet = Mockito.mock(Contentlet.class); + Mockito.when(contentlet.getContentType()).thenReturn(contentType); + Mockito.when(contentlet.getIdentifier()).thenReturn("identifier-1"); + return contentlet; + } + + private static LongTextPreviewStrategy newStrategy() { + return new LongTextPreviewStrategy(Mockito.mock(APIProvider.class)); + } + + // --- T010: WYSIWYG/TextArea -- HTML stripped, plain text truncated ------------------------ + + /** + * The map already carries the raw stored HTML under the field's variable name (the base map + * is a copy of the contentlet's own field map, seeded before any strategy runs). This must be + * replaced with Jsoup-extracted plain text, truncated to 150 characters -- not 150 characters + * of the raw HTML with tags still embedded. + */ + @Test + public void transform_wysiwygField_htmlStrippedAndTruncatedToPlainText() throws Exception { + final Field wysiwygField = mockField(WysiwygField.class, WYSIWYG_VAR); + final ContentType contentType = mockContentType(List.of(wysiwygField), List.of(), List.of()); + final Contentlet contentlet = mockContentlet(contentType); + + final String longBody = "

" + "word ".repeat(60) + "

"; + final Map map = new HashMap<>(); + map.put(WYSIWYG_VAR, longBody); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + final Object result = map.get(WYSIWYG_VAR); + assertTrue("Result must be a String", result instanceof String); + final String preview = (String) result; + assertTrue("Preview must be <=150 chars", preview.length() <= 150); + assertFalse("Preview must not contain HTML tags", preview.contains("<") || preview.contains(">")); + assertTrue("Preview must contain the extracted plain text", preview.startsWith("word word")); + } + + /** Same extraction/truncation rule applies to TextArea fields, not just WYSIWYG. */ + @Test + public void transform_textAreaField_htmlStrippedAndTruncatedToPlainText() throws Exception { + final Field textAreaField = mockField(TextAreaField.class, TEXTAREA_VAR); + final ContentType contentType = mockContentType(List.of(), List.of(textAreaField), List.of()); + final Contentlet contentlet = mockContentlet(contentType); + + final Map map = new HashMap<>(); + map.put(TEXTAREA_VAR, "Short body"); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + assertEquals("Short body", map.get(TEXTAREA_VAR)); + } + + /** A short WYSIWYG value under 150 characters is not padded or altered beyond HTML stripping. */ + @Test + public void transform_wysiwygField_shortValue_isNotTruncated() throws Exception { + final Field wysiwygField = mockField(WysiwygField.class, WYSIWYG_VAR); + final ContentType contentType = mockContentType(List.of(wysiwygField), List.of(), List.of()); + final Contentlet contentlet = mockContentlet(contentType); + + final Map map = new HashMap<>(); + map.put(WYSIWYG_VAR, "

Hello world

"); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + assertEquals("Hello world", map.get(WYSIWYG_VAR)); + } + + // --- T011: Story Block -- recursive traversal + truncation, run after StoryBlockViewStrategy + + /** + * By the time this strategy runs (declared after {@code STORY_BLOCK_VIEW} in the enum), the + * map entry for a Story Block field is already the {@link LinkedHashMap} that + * {@link StoryBlockViewStrategy} produced. The traversal must walk nested {@code content} + * arrays/tables and collect every {@code text} leaf value into a single, truncated preview. + */ + @Test + public void transform_storyBlockField_nestedListsAndTables_extractsAndConcatenatesText() + throws Exception { + final Field storyField = mockField(StoryBlockField.class, STORY_VAR); + final ContentType contentType = mockContentType(List.of(), List.of(), List.of(storyField)); + final Contentlet contentlet = mockContentlet(contentType); + + // A doc with a paragraph, a bullet list (two items) and a table cell, mirroring the + // Story Block JSON shape StoryBlockViewStrategy produces. + final Map textNode1 = Map.of("type", "text", "text", "Launch announcement"); + final Map paragraph = Map.of("type", "paragraph", "content", List.of(textNode1)); + + final Map listItemText1 = Map.of("type", "text", "text", "First point"); + final Map listItemText2 = Map.of("type", "text", "text", "Second point"); + final Map listItem1 = Map.of("type", "listItem", "content", + List.of(Map.of("type", "paragraph", "content", List.of(listItemText1)))); + final Map listItem2 = Map.of("type", "listItem", "content", + List.of(Map.of("type", "paragraph", "content", List.of(listItemText2)))); + final Map bulletList = Map.of("type", "bulletList", "content", + List.of(listItem1, listItem2)); + + final Map tableCellText = Map.of("type", "text", "text", "Cell value"); + final Map tableCell = Map.of("type", "tableCell", "content", + List.of(Map.of("type", "paragraph", "content", List.of(tableCellText)))); + final Map tableRow = Map.of("type", "tableRow", "content", List.of(tableCell)); + final Map table = Map.of("type", "table", "content", List.of(tableRow)); + + final LinkedHashMap storyBlockDoc = new LinkedHashMap<>(); + storyBlockDoc.put("type", "doc"); + storyBlockDoc.put("content", List.of(paragraph, bulletList, table)); + + final Map map = new HashMap<>(); + map.put(STORY_VAR, storyBlockDoc); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + final Object result = map.get(STORY_VAR); + assertTrue("Result must be a plain String, not the LinkedHashMap", result instanceof String); + final String preview = (String) result; + assertTrue(preview.contains("Launch announcement")); + assertTrue(preview.contains("First point")); + assertTrue(preview.contains("Second point")); + assertTrue(preview.contains("Cell value")); + assertTrue("Preview must be <=150 chars", preview.length() <= 150); + } + + /** + * {@link StoryBlockViewStrategy} falls back to the raw string when the field's value is not + * valid JSON. The traversal must treat that raw string as plain text (truncate, don't throw). + */ + @Test + public void transform_storyBlockField_nonJsonFallbackString_truncatesWithoutThrowing() + throws Exception { + final Field storyField = mockField(StoryBlockField.class, STORY_VAR); + final ContentType contentType = mockContentType(List.of(), List.of(), List.of(storyField)); + final Contentlet contentlet = mockContentlet(contentType); + + final Map map = new HashMap<>(); + map.put(STORY_VAR, "not valid json at all, just plain legacy text ".repeat(5)); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + final Object result = map.get(STORY_VAR); + assertTrue(result instanceof String); + assertTrue(((String) result).length() <= 150); + } + + /** + * {@link StoryBlockViewStrategy} leaves the map entry {@code null} when JSON parsing itself + * throws. The traversal must not throw on {@code null} and must resolve to an empty preview. + */ + @Test + public void transform_storyBlockField_nullAfterParseFailure_doesNotThrow() throws Exception { + final Field storyField = mockField(StoryBlockField.class, STORY_VAR); + final ContentType contentType = mockContentType(List.of(), List.of(), List.of(storyField)); + final Contentlet contentlet = mockContentlet(contentType); + + final Map map = new HashMap<>(); + map.put(STORY_VAR, null); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + assertEquals("", map.get(STORY_VAR)); + } + + /** No in-scope fields on the content type: the map passes through untouched. */ + @Test + public void transform_noInScopeFields_mapUnchanged() throws Exception { + final ContentType contentType = mockContentType(List.of(), List.of(), List.of()); + final Contentlet contentlet = mockContentlet(contentType); + + final Map map = new HashMap<>(); + map.put("title", "Some title"); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + assertEquals(1, map.size()); + assertEquals("Some title", map.get("title")); + } + + // --- T012: TransformOptions ordinal placement ---------------------------------------------- + + /** + * {@code LONG_TEXT_PREVIEW} must be declared after both {@code STORY_BLOCK_VIEW} and + * {@code JSON_VIEW} so {@code EnumSet} iteration order (which {@code StrategyResolverImpl} + * relies on) runs this strategy last, seeing the fully-decorated map. Guards against a future + * enum reorder silently breaking that ordering. + */ + @Test + public void longTextPreview_ordinalIsAfterStoryBlockViewAndJsonView() { + assertTrue("LONG_TEXT_PREVIEW must sort after STORY_BLOCK_VIEW", + TransformOptions.LONG_TEXT_PREVIEW.ordinal() > TransformOptions.STORY_BLOCK_VIEW.ordinal()); + assertTrue("LONG_TEXT_PREVIEW must sort after JSON_VIEW", + TransformOptions.LONG_TEXT_PREVIEW.ordinal() > TransformOptions.JSON_VIEW.ordinal()); + } + + // --- T013: defaultOptions unaffected (AC-007) ----------------------------------------------- + + /** + * {@code LONG_TEXT_PREVIEW} must never be part of the shared {@code defaultOptions} set -- + * every consumer that builds a transformer via {@code .defaultOptions()} (URL content map, + * ContentResource, GraphQL, the Content Editor) must stay byte-identical to today. It is wired + * opt-in, only at {@code BrowserAPIImpl#dotContentMap}'s specific call site. + */ + @Test + public void defaultOptions_neverIncludesLongTextPreview() { + assertFalse("LONG_TEXT_PREVIEW must not be part of the shared defaultOptions set", + DotContentletTransformerImpl.defaultOptions.contains(TransformOptions.LONG_TEXT_PREVIEW)); + } + + // --- T014: StrategyResolverImpl registers the new option-triggered strategy ---------------- + + /** + * Confirms {@code StrategyResolverImpl.resolveStrategies} actually resolves and returns a + * {@link LongTextPreviewStrategy} instance when {@code LONG_TEXT_PREVIEW} is requested -- not + * just that the class itself constructs. + */ + @Test + public void resolveStrategies_longTextPreviewOption_resolvesLongTextPreviewStrategy() { + final StrategyResolverImpl resolver = new StrategyResolverImpl(Mockito.mock(APIProvider.class)); + + final List strategies = resolver.resolveStrategies(null, + EnumSet.of(TransformOptions.LONG_TEXT_PREVIEW)); + + assertTrue("Must resolve a LongTextPreviewStrategy instance", + strategies.stream().anyMatch(s -> s instanceof LongTextPreviewStrategy)); + } + + /** Without the option, no LongTextPreviewStrategy is resolved. */ + @Test + public void resolveStrategies_withoutLongTextPreviewOption_doesNotResolveIt() { + final StrategyResolverImpl resolver = new StrategyResolverImpl(Mockito.mock(APIProvider.class)); + + final List strategies = resolver.resolveStrategies(null, + EnumSet.of(TransformOptions.STORY_BLOCK_VIEW)); + + assertTrue(strategies.stream().noneMatch(s -> s instanceof LongTextPreviewStrategy)); + } +} From c28668b3233069dfe6be7e056879ae7258e0fa95 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 3 Sep 2026 21:49:37 -0300 Subject: [PATCH 2/6] fix(content-drive): truncate long-text field values in listing rows (#37185) Adds TransformOptions.LONG_TEXT_PREVIEW (declared after STORY_BLOCK_VIEW/ JSON_VIEW so EnumSet order runs it last) backed by LongTextPreviewStrategy, which replaces WYSIWYG/TextArea/Story Block values with a <=150-char extracted plain-text preview. Wired opt-in only at BrowserAPIImpl#dotContentMap via a new DotTransformerBuilder#longTextPreview() chain method -- never added to defaultOptions, so no other transformer consumer is affected. --- .../com/dotcms/browser/BrowserAPIImpl.java | 6 +- .../transform/DotTransformerBuilder.java | 14 ++ .../strategy/LongTextPreviewStrategy.java | 130 ++++++++++++++++++ .../strategy/StrategyResolverImpl.java | 2 + .../transform/strategy/TransformOptions.java | 10 +- 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index cec5b06df168..fdd66ee8b60c 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -3042,7 +3042,11 @@ private Map dotAssetMap(final Contentlet dotAsset) throws DotStat } // dotAssetMap. private Map dotContentMap(final Contentlet dotAsset) throws DotStateException { - return new DotTransformerBuilder().defaultOptions().content(dotAsset).build().toMaps().get(0); + // issue #37185: opt-in only at this call site -- never added to defaultOptions -- so no + // other consumer of DotTransformerBuilder#defaultOptions() (ContentResource, GraphQL, the + // Content Editor, etc.) is affected. + return new DotTransformerBuilder().defaultOptions().longTextPreview().content(dotAsset) + .build().toMaps().get(0); } // dotAssetMap. diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java index 2680c2bc4536..d386ba343ab1 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/DotTransformerBuilder.java @@ -246,6 +246,20 @@ public DotTransformerBuilder defaultOptions(){ return this; } + /** + * Opts into replacing WYSIWYG/TextArea/Story Block field values with a <=150-character + * extracted plain-text preview (issue #37185). Additive -- chain it after any of this + * builder's other option methods (e.g. {@link #defaultOptions()}) without disturbing their + * options. Never added to {@link DotContentletTransformerImpl#defaultOptions} itself, so this + * remains strictly opt-in per call site. + * + * @return The {@link DotTransformerBuilder} instance. + */ + public DotTransformerBuilder longTextPreview(){ + optionsHolder.add(TransformOptions.LONG_TEXT_PREVIEW); + return this; + } + /** * This transformer provides a view for the History of a Contentlet. It exposes a minified map * of properties, just like the data you can see in the History tab in the Content Editor page. diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java new file mode 100644 index 000000000000..acc38dfcf317 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java @@ -0,0 +1,130 @@ +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 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. + *

+ * 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 { + + static final int MAX_PREVIEW_LENGTH = 150; + + LongTextPreviewStrategy(final APIProvider toolBox) { + super(toolBox); + } + + @Override + protected Map transform(final Contentlet source, final Map map, + final Set 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 fields, final Map map, + final Function extractor) { + if (!UtilMethods.isSet(fields)) { + return; + } + fields.forEach(field -> Try.run(() -> + map.put(field.variable(), extractor.apply(map.get(field.variable())))) + .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()); + } + + /** + * 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. */ + private static void collectText(final Object node, final StringBuilder out) { + 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) { + collectText(child, out); + } + } + } else if (node instanceof List) { + for (final Object child : (List) node) { + collectText(child, out); + } + } + } + + private static String truncate(final String text) { + if (null == text) { + return StringPool.BLANK; + } + return text.length() <= MAX_PREVIEW_LENGTH ? text : text.substring(0, MAX_PREVIEW_LENGTH); + } + +} diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java index 958a924b5eaa..966f091914c9 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/StrategyResolverImpl.java @@ -22,6 +22,7 @@ import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.JSON_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.KEY_VALUE_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.LANGUAGE_VIEW; +import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.LONG_TEXT_PREVIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.RENDER_FIELDS; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.SITE_VIEW; import static com.dotmarketing.portlets.contentlet.transform.strategy.TransformOptions.STORY_BLOCK_VIEW; @@ -102,6 +103,7 @@ private static Map> getStr strategyTriggeredByOptionMap.put(JSON_VIEW, () -> new JSONViewStrategy(toolBox)); strategyTriggeredByOptionMap.put(DATETIME_FIELDS_TO_TIMESTAMP, () -> new DateTimeFieldsToTimeStampStrategy(toolBox)); strategyTriggeredByOptionMap.put(HISTORY_VIEW, () -> new HistoryViewStrategy(toolBox)); + strategyTriggeredByOptionMap.put(LONG_TEXT_PREVIEW, () -> new LongTextPreviewStrategy(toolBox)); return strategyTriggeredByOptionMap; } diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java index e6dd1f17b08c..207ac251e76c 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/TransformOptions.java @@ -110,7 +110,15 @@ public enum TransformOptions { HISTORY_VIEW, /** Instructs the Strategy to clear all existing data in the Contentlet Map before applying a * specific Strategy. */ - CLEAR_EXISTING_DATA; + CLEAR_EXISTING_DATA, + /** + * Instructs the Strategy to replace WYSIWYG/TextArea/Story Block field values with a + * <=150-character extracted plain-text preview. Declared last (after {@link #STORY_BLOCK_VIEW} + * and {@link #JSON_VIEW}) so {@code EnumSet} iteration order in + * {@link StrategyResolverImpl#resolveStrategies} runs this strategy after those have already + * decorated the map -- see issue #37185. + */ + LONG_TEXT_PREVIEW; // ----------------------------------------------------------------------------------------- // Plug additional Transform Options to manipulate the outcome as a particular type of view From 12c500d884f8643db7ea4fdf4909f831072685fd Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 3 Sep 2026 21:52:03 -0300 Subject: [PATCH 3/6] fix(content-drive): never overwrite the 'title' key with a truncated preview (#37185, AC-008) If a content type's title-source field is itself WYSIWYG/TextArea (its variable is literally 'title'), LongTextPreviewStrategy would match it by field type and clobber the value COMMON_PROPS already populated from Contentlet#getTitle() with a truncated/HTML-stripped preview. Skip the 'title' key explicitly. --- .../strategy/LongTextPreviewStrategy.java | 16 +++++++---- .../strategy/LongTextPreviewStrategyTest.java | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java index acc38dfcf317..73df86bc3f8f 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java @@ -10,6 +10,7 @@ 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; @@ -64,11 +65,16 @@ private void applyPreview(final List fields, final Map ma if (!UtilMethods.isSet(fields)) { return; } - fields.forEach(field -> Try.run(() -> - map.put(field.variable(), extractor.apply(map.get(field.variable())))) - .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())))); + 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())))) + .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. */ diff --git a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java index a8f394e72772..5d9d54d62df1 100644 --- a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java @@ -228,6 +228,33 @@ public void transform_noInScopeFields_mapUnchanged() throws Exception { assertEquals("Some title", map.get("title")); } + /** + * AC-008: when a content type's title-source field is itself a WYSIWYG or TextArea field + * (i.e. its variable is literally {@code "title"}), the {@code title} map key -- already + * populated independently by {@code DefaultTransformStrategy}/{@code COMMON_PROPS} from + * {@code Contentlet#getTitle()} -- must NOT be overwritten with a truncated preview. Found + * while designing this coverage: without the guard, {@code LongTextPreviewStrategy} would + * match the field by its WYSIWYG type and clobber the already-correct {@code title} value. + */ + @Test + public void transform_wysiwygFieldNamedTitle_doesNotOverwriteTitleKey() throws Exception { + final Field titleField = mockField(WysiwygField.class, "title"); + final ContentType contentType = mockContentType(List.of(titleField), List.of(), List.of()); + final Contentlet contentlet = mockContentlet(contentType); + + // Simulates DefaultTransformStrategy/COMMON_PROPS having already run and populated + // "title" from Contentlet#getTitle() -- untruncated, HTML markup and all in this + // worst-case scenario, since getTitle() does not itself strip HTML. + final String realTitle = "

" + "word ".repeat(60) + "

"; + final Map map = new HashMap<>(); + map.put("title", realTitle); + + newStrategy().transform(contentlet, map, EnumSet.noneOf(TransformOptions.class), null); + + assertEquals("The 'title' key must be untouched by the long-text preview strategy", + realTitle, map.get("title")); + } + // --- T012: TransformOptions ordinal placement ---------------------------------------------- /** From 743b2ef3c8d96236d3670e0df5c15fd7bd97d558 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 3 Sep 2026 21:53:24 -0300 Subject: [PATCH 4/6] test(content-drive): pin blast-radius regression for long-text preview trim (#37185) T030-T033: generic-Content row shape from getPaginatedContents (Drive) and getFolderContent (Site Browser) both carry AC-002's required keys and reduced long-text values; Show In List (AC-003) renders a readable preview; a WYSIWYG title-source field (AC-008) keeps an untruncated title. Also: AC-005 Schema description update on ContentDriveResource#search (endpoint is @Hidden, no openapi.yaml regen needed), and AC-006 Postman fix removing the dead item.body read (a listing row never carried that key) in favor of an assertion that actually runs. --- .../api/v1/drive/ContentDriveResource.java | 2 +- .../com/dotcms/browser/BrowserAPITest.java | 250 ++++++++++++++++++ ...ntentDriveResource.postman_collection.json | 11 +- 3 files changed, 257 insertions(+), 6 deletions(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java index e9b847653ccb..d3a4a5ccd461 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java @@ -67,7 +67,7 @@ public class ContentDriveResource { description = "Drive search results retrieved successfully", content = @Content(mediaType = "application/json", schema = @Schema(type = "object", - description = "Drive search response containing filtered assets, folders, and navigation metadata with content type filtering") + description = "Drive search response containing filtered assets, folders, and navigation metadata with content type filtering. WYSIWYG/TextArea/Story Block field values on each listing row are a <=150-character extracted plain-text preview, not the full stored value (issue #37185).") ) ), @ApiResponse(responseCode = "401", diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java index b32e4d32cb37..f5927c3884e6 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -9,6 +9,10 @@ import com.dotcms.IntegrationTestBase; import com.dotcms.browser.BrowserAPIImpl.PaginatedContents; import com.dotcms.contenttype.business.ContentTypeAPI; +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.datagen.ContentTypeDataGen; import com.dotcms.datagen.ContentletDataGen; import com.dotcms.datagen.DotAssetDataGen; @@ -2531,4 +2535,250 @@ private static File fileNamed(final String name) throws IOException { FileUtils.writeStringToFile(file, "this is a test!", StandardCharsets.UTF_8); return file; } + + // ------------------------------------------------------------------------------------------ + // issue #37185 -- long-text listing projection trim (blast-radius regression, US2). + // + // T030-T033 from specs/37185-content-drive-listing-longtext-projection/tasks.md. Pins the + // generic-Content row shape from both getPaginatedContents (Content Drive) and + // getFolderContent (Site Browser), which share dotContentMap. + + private static final String LTP_WYSIWYG_VAR = "ltpWysiwyg"; + private static final String LTP_TEXTAREA_VAR = "ltpTextArea"; + private static final String LTP_STORY_VAR = "ltpStory"; + + /** AC-002: every field the Content Drive grid/toolbar/action menu depend on. */ + private static final List REQUIRED_LISTING_KEYS = List.of( + "identifier", "inode", "title", "contentType", "baseType", "languageId", "live", + "working", "archived", "hasLiveVersion", "modUser", "modUserName", "modDate", + "permissions", "__icon__", "mimeType", "extension", "hasTitleImage", "owner"); + + private static String storyBlockJson(final String text) { + return "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":" + + "[{\"type\":\"text\",\"text\":\"" + text + "\"}]}]}"; + } + + private static ContentType createLongTextContentType(final String uniqueId) { + final ContentType contentType = new ContentTypeDataGen() + .name("ltpType_" + uniqueId) + .velocityVarName("ltpType_" + uniqueId) + .nextPersisted(); + new FieldDataGen().type(WysiwygField.class).name(LTP_WYSIWYG_VAR) + .velocityVarName(LTP_WYSIWYG_VAR).contentTypeId(contentType.id()) + .searchable(true).indexed(true).nextPersisted(); + new FieldDataGen().type(TextAreaField.class).name(LTP_TEXTAREA_VAR) + .velocityVarName(LTP_TEXTAREA_VAR).contentTypeId(contentType.id()) + .searchable(true).indexed(true).nextPersisted(); + new FieldDataGen().type(StoryBlockField.class).name(LTP_STORY_VAR) + .velocityVarName(LTP_STORY_VAR).contentTypeId(contentType.id()) + .searchable(true).indexed(true).nextPersisted(); + return contentType; + } + + private static void assertRequiredKeysPresent(final Map row) { + for (final String key : REQUIRED_LISTING_KEYS) { + assertTrue("Row must carry required key '" + key + "': " + row.keySet(), + row.containsKey(key)); + } + } + + private static void assertLongTextValuesArePreviews(final Map row, + final String rawHtmlBody) { + for (final String var : List.of(LTP_WYSIWYG_VAR, LTP_TEXTAREA_VAR, LTP_STORY_VAR)) { + final Object value = row.get(var); + assertTrue("'" + var + "' must be a String preview", value instanceof String); + final String preview = (String) value; + assertTrue("'" + var + "' preview must be <=150 chars", preview.length() <= 150); + assertFalse("'" + var + "' preview must not contain HTML markers", + preview.contains("<") || preview.contains(">")); + assertFalse("'" + var + "' preview must not contain JSON structure", + preview.contains("{") || preview.contains("}")); + assertTrue("'" + var + "' preview must be shorter than the raw stored value", + preview.length() < rawHtmlBody.length()); + } + } + + /** + *
    + *
  • Method to Test: {@link BrowserAPIImpl#getPaginatedContents(BrowserQuery)}
  • + *
  • Given Scenario: A generic-Content row with WYSIWYG/TextArea/Story Block field + * values, listed via the Content Drive path (T030, AC-001/AC-002).
  • + *
  • Expected Result: Every AC-002 key is present AND every long-text field value + * is a <=150-character plain-text preview, free of HTML/JSON structure.
  • + *
+ */ + @Test + public void test_getPaginatedContents_longTextFields_arePreviewedAndRequiredKeysPresent() + throws Exception { + final String uniqueId = UUIDGenerator.shorty(); + final Host site = new SiteDataGen().nextPersisted(); + final Folder folder = new FolderDataGen().site(site).nextPersisted(); + final ContentType contentType = createLongTextContentType(uniqueId); + + final String rawHtmlBody = "

" + "word ".repeat(60) + "

"; + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .folder(folder) + .setProperty("title", "ltpDoc_" + uniqueId) + .setProperty(LTP_WYSIWYG_VAR, rawHtmlBody) + .setProperty(LTP_TEXTAREA_VAR, rawHtmlBody) + .setProperty(LTP_STORY_VAR, storyBlockJson("word ".repeat(60))) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + + final PaginatedContents result = browserAPI.getPaginatedContents(BrowserQuery.builder() + .withUser(APILocator.systemUser()) + .withHostOrFolderId(folder.getIdentifier()) + .build()); + + final Map row = result.list.stream() + .filter(item -> contentlet.getIdentifier().equals(item.get("identifier"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Must find the created contentlet in the listing")); + + assertRequiredKeysPresent(row); + assertLongTextValuesArePreviews(row, rawHtmlBody); + } + + /** + *
    + *
  • Method to Test: {@link BrowserAPIImpl#getFolderContent(BrowserQuery)}
  • + *
  • Given Scenario: The same content type/data as above, listed via the Site + * Browser path (T031, AC-004).
  • + *
  • Expected Result: Same required keys present, same reduced long-text values -- + * Site Browser gets identical treatment to Content Drive since both share + * {@code dotContentMap}.
  • + *
+ */ + @Test + public void test_getFolderContent_longTextFields_arePreviewedAndRequiredKeysPresent() + throws Exception { + final String uniqueId = UUIDGenerator.shorty(); + final Host site = new SiteDataGen().nextPersisted(); + final Folder folder = new FolderDataGen().site(site).nextPersisted(); + final ContentType contentType = createLongTextContentType(uniqueId); + + final String rawHtmlBody = "

" + "word ".repeat(60) + "

"; + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .folder(folder) + .setProperty("title", "ltpSiteBrowserDoc_" + uniqueId) + .setProperty(LTP_WYSIWYG_VAR, rawHtmlBody) + .setProperty(LTP_TEXTAREA_VAR, rawHtmlBody) + .setProperty(LTP_STORY_VAR, storyBlockJson("word ".repeat(60))) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + + @SuppressWarnings("unchecked") + final Map results = browserAPI.getFolderContent(BrowserQuery.builder() + .withUser(APILocator.systemUser()) + .withHostOrFolderId(folder.getIdentifier()) + .build()); + @SuppressWarnings("unchecked") + final List> list = (List>) results.get("list"); + + final Map row = list.stream() + .filter(item -> contentlet.getIdentifier().equals(item.get("identifier"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Must find the created contentlet in the listing")); + + assertRequiredKeysPresent(row); + assertLongTextValuesArePreviews(row, rawHtmlBody); + } + + /** + *
    + *
  • Given Scenario: A content type with a {@code listed} (Show In List) WYSIWYG + * field (T032, AC-003).
  • + *
  • Expected Result: The grid column's cell value is present, a <=150-character + * plain-text preview -- not the full body, not blank, not mid-tag garbage.
  • + *
+ */ + @Test + public void test_getPaginatedContents_listedWysiwygField_rendersReadablePreview() throws Exception { + final String uniqueId = UUIDGenerator.shorty(); + final Host site = new SiteDataGen().nextPersisted(); + final Folder folder = new FolderDataGen().site(site).nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .name("ltpListedType_" + uniqueId) + .velocityVarName("ltpListedType_" + uniqueId) + .nextPersisted(); + new FieldDataGen().type(WysiwygField.class).name(LTP_WYSIWYG_VAR) + .velocityVarName(LTP_WYSIWYG_VAR).contentTypeId(contentType.id()) + .searchable(true).indexed(true).listed(true).nextPersisted(); + + final String rawHtmlBody = "

" + "article body text ".repeat(30) + "

"; + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .folder(folder) + .setProperty("title", "ltpListedDoc_" + uniqueId) + .setProperty(LTP_WYSIWYG_VAR, rawHtmlBody) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + + final PaginatedContents result = browserAPI.getPaginatedContents(BrowserQuery.builder() + .withUser(APILocator.systemUser()) + .withHostOrFolderId(folder.getIdentifier()) + .build()); + + final Map row = result.list.stream() + .filter(item -> contentlet.getIdentifier().equals(item.get("identifier"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Must find the created contentlet in the listing")); + + final Object value = row.get(LTP_WYSIWYG_VAR); + assertTrue("Listed WYSIWYG column must be present", row.containsKey(LTP_WYSIWYG_VAR)); + assertTrue(value instanceof String); + final String preview = (String) value; + assertFalse("Must not be blank", preview.isEmpty()); + assertTrue("Must be <=150 chars", preview.length() <= 150); + assertFalse("Must not contain HTML tags", preview.contains("<") || preview.contains(">")); + } + + /** + *
    + *
  • Given Scenario: A content type whose title-source field is itself a WYSIWYG + * field (its variable is literally {@code "title"}) (T033, AC-008).
  • + *
  • Expected Result: The listing's {@code title} key is the correct, untruncated + * title -- not derived from the same map entry the long-text preview strategy truncates.
  • + *
+ */ + @Test + public void test_getPaginatedContents_wysiwygTitleField_titleKeyStaysUntruncated() throws Exception { + final String uniqueId = UUIDGenerator.shorty(); + final Host site = new SiteDataGen().nextPersisted(); + final Folder folder = new FolderDataGen().site(site).nextPersisted(); + + final ContentType contentType = new ContentTypeDataGen() + .name("ltpTitleType_" + uniqueId) + .velocityVarName("ltpTitleType_" + uniqueId) + .nextPersisted(); + // The title-source field: WYSIWYG, variable name "title" -- Contentlet#getTitle() nominates + // the first field whose variable starts with "title" when no separate title is set. + new FieldDataGen().type(WysiwygField.class).name("Title") + .velocityVarName("title").contentTypeId(contentType.id()) + .searchable(true).indexed(true).nextPersisted(); + + final String longTitleHtml = "

" + "TitleWord ".repeat(40) + "

"; + final Contentlet contentlet = new ContentletDataGen(contentType.id()) + .folder(folder) + .setProperty("title", longTitleHtml) + .languageId(1) + .setPolicy(IndexPolicy.WAIT_FOR) + .nextPersisted(); + + final PaginatedContents result = browserAPI.getPaginatedContents(BrowserQuery.builder() + .withUser(APILocator.systemUser()) + .withHostOrFolderId(folder.getIdentifier()) + .build()); + + final Map row = result.list.stream() + .filter(item -> contentlet.getIdentifier().equals(item.get("identifier"))) + .findFirst() + .orElseThrow(() -> new AssertionError("Must find the created contentlet in the listing")); + + assertEquals("The title key must equal Contentlet#getTitle(), untruncated", + contentlet.getTitle(), row.get("title")); + } } diff --git a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json index ec1c45345966..15d514df33cd 100644 --- a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json @@ -991,15 +991,16 @@ " var jsonData = pm.response.json();", " var list = jsonData.entity.list;", " ", - " // Should not include items without 'Alpha' when filtering", + " // A listing row never carries a 'body' key -- generic-Content long-text field", + " // values (WYSIWYG/TextArea/Story Block) are a <=150-char preview under their own", + " // field variable, not a fixed 'body' key (issue #37185). Assert against the field", + " // this endpoint actually returns: every result must match the filter on 'title'.", " var allItemsMatch = list.every(item => {", " var title = item.title || item.name || '';", - " var body = item.body || '';", - " return title.toLowerCase().includes('alpha') || body.toLowerCase().includes('alpha');", + " return title.toLowerCase().includes('alpha');", " });", " ", - " // Note: Due to Elasticsearch behavior, this might not be 100% strict", - " // but most results should match", + " pm.expect(allItemsMatch).to.be.true;", " pm.expect(list.length).to.be.at.most(10); // Should be filtered down", "});" ], From 07b517809f259d7bdf40919e74edec9b4e3049e9 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Fri, 4 Sep 2026 21:37:59 -0300 Subject: [PATCH 5/6] fix(content-drive): fix real bugs found running LongTextPreviewStrategyTest/BrowserAPITest (#37185) - defaultOptions_neverIncludesLongTextPreview referenced DotContentletTransformerImpl.defaultOptions directly across packages (...transform vs. this test's ...transform.strategy) -- the field is package-private, so it doesn't compile. Read it via reflection instead. - The wysiwygTitleField test's long title HTML (400+ chars) exceeded the contentlet.title column's varchar(255) limit. Reduced while keeping the stripped plain text well over the 150-char preview bound. - REQUIRED_LISTING_KEYS listed the actual icon key as '__icon__' (it's 'icon') and included mimeType/extension, which are File Asset-specific and legitimately absent on a generic-Content row. --- .../strategy/LongTextPreviewStrategyTest.java | 14 ++++++++++++-- .../java/com/dotcms/browser/BrowserAPITest.java | 13 ++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java index 5d9d54d62df1..a28110e469e5 100644 --- a/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java +++ b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java @@ -280,9 +280,19 @@ public void longTextPreview_ordinalIsAfterStoryBlockViewAndJsonView() { * opt-in, only at {@code BrowserAPIImpl#dotContentMap}'s specific call site. */ @Test - public void defaultOptions_neverIncludesLongTextPreview() { + public void defaultOptions_neverIncludesLongTextPreview() throws Exception { + // Package-private field on a different package (com.dotmarketing.portlets.contentlet. + // transform, not this class's ...transform.strategy) -- read via reflection. + final java.lang.reflect.Field defaultOptionsField = Class + .forName("com.dotmarketing.portlets.contentlet.transform.DotContentletTransformerImpl") + .getDeclaredField("defaultOptions"); + defaultOptionsField.setAccessible(true); + @SuppressWarnings("unchecked") + final java.util.Set defaultOptions = + (java.util.Set) defaultOptionsField.get(null); + assertFalse("LONG_TEXT_PREVIEW must not be part of the shared defaultOptions set", - DotContentletTransformerImpl.defaultOptions.contains(TransformOptions.LONG_TEXT_PREVIEW)); + defaultOptions.contains(TransformOptions.LONG_TEXT_PREVIEW)); } // --- T014: StrategyResolverImpl registers the new option-triggered strategy ---------------- diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java index f5927c3884e6..6cd5dc5d3b53 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -2547,11 +2547,15 @@ private static File fileNamed(final String name) throws IOException { private static final String LTP_TEXTAREA_VAR = "ltpTextArea"; private static final String LTP_STORY_VAR = "ltpStory"; - /** AC-002: every field the Content Drive grid/toolbar/action menu depend on. */ + /** + * AC-002: every field the Content Drive grid/toolbar/action menu depend on, for a + * generic-Content row. {@code mimeType}/{@code extension} are File Asset-specific and + * legitimately absent here (found running this test against a generic content type). + */ private static final List REQUIRED_LISTING_KEYS = List.of( "identifier", "inode", "title", "contentType", "baseType", "languageId", "live", "working", "archived", "hasLiveVersion", "modUser", "modUserName", "modDate", - "permissions", "__icon__", "mimeType", "extension", "hasTitleImage", "owner"); + "permissions", "icon", "hasTitleImage", "owner"); private static String storyBlockJson(final String text) { return "{\"type\":\"doc\",\"content\":[{\"type\":\"paragraph\",\"content\":" @@ -2760,7 +2764,10 @@ public void test_getPaginatedContents_wysiwygTitleField_titleKeyStaysUntruncated .velocityVarName("title").contentTypeId(contentType.id()) .searchable(true).indexed(true).nextPersisted(); - final String longTitleHtml = "

" + "TitleWord ".repeat(40) + "

"; + // Kept under 255 chars (raw HTML) -- the contentlet.title column is varchar(255) -- while + // its stripped plain text (~220 chars) still comfortably exceeds the 150-char preview + // bound, so an accidental truncation of this key would be caught. + final String longTitleHtml = "

" + "TitleWord ".repeat(22) + "

"; final Contentlet contentlet = new ContentletDataGen(contentType.id()) .folder(folder) .setProperty("title", longTitleHtml) From 71082046da69726237f5e2e2de229d8bcf5a6828 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Mon, 7 Sep 2026 22:03:16 -0300 Subject: [PATCH 6/6] fix(content-drive): short-circuit story-block traversal, avoid surrogate-pair split (#37185) Found in code review (claude[bot] on PR #37396): - collectText walked the entire story-block tree before truncate() threw away everything past 150 chars -- full-payload work on the exact path this feature exists to keep cheap. Short-circuit once enough text is collected. - truncate() could split a UTF-16 surrogate pair (emoji, some CJK) at the 150-char boundary, leaving a lone high surrogate. Back off one char when the boundary char is a high surrogate. --- .../strategy/LongTextPreviewStrategy.java | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java index 73df86bc3f8f..b53c92124b85 100644 --- a/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java @@ -102,8 +102,15 @@ private static String extractStoryBlockPreview(final Object storyBlockValue) { return truncate(textBuilder.toString()); } - /** Recursively walks a Story Block JSON-tree node, collecting every {@code text} leaf value. */ + /** + * 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"); @@ -116,11 +123,17 @@ private static void collectText(final Object node, final StringBuilder out) { 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); } } @@ -130,7 +143,14 @@ private static String truncate(final String text) { if (null == text) { return StringPool.BLANK; } - return text.length() <= MAX_PREVIEW_LENGTH ? text : text.substring(0, MAX_PREVIEW_LENGTH); + 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); } }