From 49228fc3536a01a83a86fc0b5b396ffa10d1ada3 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Thu, 3 Sep 2026 21:47:59 -0300 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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); } } From e7b9e75f30abe2e922491de1f715aa12a771d82d Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 9 Sep 2026 23:16:51 -0300 Subject: [PATCH 07/11] fix(content-drive): skip fields absent from the row map in long-text preview (#37185) Adds a containsKey guard so a field with no value on the row is left absent instead of getting a synthesized "" preview entry, keeping the strategy purely subtractive (review feedback from Fabrizzio). --- .../transform/strategy/LongTextPreviewStrategy.java | 4 ++++ 1 file changed, 4 insertions(+) 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 b53c92124b85..b19437fdb683 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 @@ -70,6 +70,10 @@ private void applyPreview(final List fields, final Map ma // 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())) + // A field entirely absent from the row's map must stay absent -- otherwise every + // in-scope field on the content type gets a synthesized "" entry, growing the + // payload this strategy exists to shrink (found in review). + .filter(field -> map.containsKey(field.variable())) .forEach(field -> Try.run(() -> map.put(field.variable(), extractor.apply(map.get(field.variable())))) .onFailure(e -> Logger.warn(LongTextPreviewStrategy.class, String.format( From f523d4d0703203c91e8075232172b2d15378e5fd Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 9 Sep 2026 23:17:03 -0300 Subject: [PATCH 08/11] fix(content-drive): bound Jsoup.parse to a small prefix for HTML previews (#37185) Parsing the full stored HTML body just to keep a 150-char preview is expensive and asymmetric with the StoryBlock traversal's short-circuit (commit 71082046da). Bound the parse to HTML_PARSE_BUDGET characters -- Jsoup tolerates truncated markup and the budget covers the preview length with wide margin (review feedback from Fabrizzio). --- .../transform/strategy/LongTextPreviewStrategy.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) 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 b19437fdb683..0a4570813769 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 @@ -39,6 +39,14 @@ public class LongTextPreviewStrategy extends AbstractTransformStrategy HTML_PARSE_BUDGET + ? html.substring(0, HTML_PARSE_BUDGET) : html; + return truncate(Jsoup.parse(bounded).text()); } /** From 0f9a586597be6c3584e6cc04dd829cfe13146c62 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 9 Sep 2026 23:17:08 -0300 Subject: [PATCH 09/11] fix(content-drive): relax overly strict Postman assertion on Alpha filter test (#37185) Requiring every returned row's title to contain 'alpha' is stricter than the search contract guarantees and risks flaking if matches ever come from another field. Assert at least one match instead -- the unfiltered baseline count isn't available in this test to assert narrowing (review feedback from Fabrizzio). --- .../ContentDriveResource.postman_collection.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 15d514df33cd..dac705585c67 100644 --- a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json @@ -993,14 +993,16 @@ " ", " // 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 => {", + " // field variable, not a fixed 'body' key (issue #37185). Requiring EVERY result", + " // to match on 'title' is stricter than the search contract guarantees (the", + " // endpoint may match on other fields too), so only assert at least one match --", + " // the unfiltered baseline count isn't available here to assert narrowing instead.", + " var hasMatchingItem = list.some(item => {", " var title = item.title || item.name || '';", " return title.toLowerCase().includes('alpha');", " });", " ", - " pm.expect(allItemsMatch).to.be.true;", + " pm.expect(hasMatchingItem).to.be.true;", " pm.expect(list.length).to.be.at.most(10); // Should be filtered down", "});" ], From 872a93cf17a6bef8e16a570a5c710b4a2bc44d5e Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 9 Sep 2026 23:17:13 -0300 Subject: [PATCH 10/11] test(content-drive): pin AC-002 payload-size reduction for long-text preview (#37185) None of the existing assertions verified the PR's one quantitative acceptance criterion (payload drops by at least half). Add assertPayloadSizeDropsByAtLeastHalf, comparing serialized JSON bytes for the long-text fields before (raw stored values) and after (the row's actual previews), scoped to just those fields so shared required-key overhead doesn't dilute the ratio (review feedback from Fabrizzio). --- .../com/dotcms/browser/BrowserAPITest.java | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) 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 6cd5dc5d3b53..c92f66c10732 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue; import com.dotcms.IntegrationTestBase; +import com.fasterxml.jackson.databind.ObjectMapper; import com.dotcms.browser.BrowserAPIImpl.PaginatedContents; import com.dotcms.contenttype.business.ContentTypeAPI; import com.dotcms.contenttype.model.field.StoryBlockField; @@ -74,6 +75,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; @@ -2602,6 +2604,38 @@ private static void assertLongTextValuesArePreviews(final Map ro } } + /** + * AC-002: "Payload for a 40-row page of long-body generic Content drops by at least half + * versus current behavior." Compares serialized JSON sizes for just the three long-text + * fields -- pre-fix (raw, untruncated stored values) versus post-fix (the previews actually + * in {@code row}) -- directly, rather than relying on a proxy ratio. Scoped to only the + * affected fields (not the whole row) so the required keys shared by both pre- and post-fix + * rows don't dilute the ratio with fixed overhead unrelated to this strategy's trim (found in + * review: none of the existing assertions pinned AC-002, the PR's one quantitative + * acceptance criterion). + */ + private static void assertPayloadSizeDropsByAtLeastHalf(final Map row, + final String rawHtmlBody, final String rawStoryBlockJson) throws Exception { + final ObjectMapper objectMapper = new ObjectMapper(); + + final Map preFixFields = new LinkedHashMap<>(); + preFixFields.put(LTP_WYSIWYG_VAR, rawHtmlBody); + preFixFields.put(LTP_TEXTAREA_VAR, rawHtmlBody); + preFixFields.put(LTP_STORY_VAR, rawStoryBlockJson); + + final Map postFixFields = new LinkedHashMap<>(); + postFixFields.put(LTP_WYSIWYG_VAR, row.get(LTP_WYSIWYG_VAR)); + postFixFields.put(LTP_TEXTAREA_VAR, row.get(LTP_TEXTAREA_VAR)); + postFixFields.put(LTP_STORY_VAR, row.get(LTP_STORY_VAR)); + + final int postFixBytes = objectMapper.writeValueAsBytes(postFixFields).length; + final int preFixBytes = objectMapper.writeValueAsBytes(preFixFields).length; + + assertTrue("Post-fix long-text fields (" + postFixBytes + " bytes) must be less than " + + "half the pre-fix raw values (" + preFixBytes + " bytes) per AC-002", + postFixBytes < preFixBytes * 0.5); + } + /** *
    *
  • Method to Test: {@link BrowserAPIImpl#getPaginatedContents(BrowserQuery)}
  • @@ -2620,12 +2654,13 @@ public void test_getPaginatedContents_longTextFields_arePreviewedAndRequiredKeys final ContentType contentType = createLongTextContentType(uniqueId); final String rawHtmlBody = "

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

    "; + final String rawStoryBlockJson = storyBlockJson("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))) + .setProperty(LTP_STORY_VAR, rawStoryBlockJson) .languageId(1) .setPolicy(IndexPolicy.WAIT_FOR) .nextPersisted(); @@ -2642,6 +2677,7 @@ public void test_getPaginatedContents_longTextFields_arePreviewedAndRequiredKeys assertRequiredKeysPresent(row); assertLongTextValuesArePreviews(row, rawHtmlBody); + assertPayloadSizeDropsByAtLeastHalf(row, rawHtmlBody, rawStoryBlockJson); } /** @@ -2663,12 +2699,13 @@ public void test_getFolderContent_longTextFields_arePreviewedAndRequiredKeysPres final ContentType contentType = createLongTextContentType(uniqueId); final String rawHtmlBody = "

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

    "; + final String rawStoryBlockJson = storyBlockJson("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))) + .setProperty(LTP_STORY_VAR, rawStoryBlockJson) .languageId(1) .setPolicy(IndexPolicy.WAIT_FOR) .nextPersisted(); @@ -2688,6 +2725,7 @@ public void test_getFolderContent_longTextFields_arePreviewedAndRequiredKeysPres assertRequiredKeysPresent(row); assertLongTextValuesArePreviews(row, rawHtmlBody); + assertPayloadSizeDropsByAtLeastHalf(row, rawHtmlBody, rawStoryBlockJson); } /** From 5781a6f85a25fdc69ad51e36f3b81345e4c4be07 Mon Sep 17 00:00:00 2001 From: ihoffmann-dot Date: Wed, 9 Sep 2026 23:17:33 -0300 Subject: [PATCH 11/11] fix(content-drive): mark truncated long-text previews with an ellipsis (#37185) A hard 150-char cut is indistinguishable from a short, complete value. Append a single U+2026 marker when truncation actually drops content, budgeting one character out of the 150-char cap so the total visible length still honors AC-001. Grapheme-boundary-aware truncation is explicitly out of scope per review (review feedback from Fabrizzio). --- .../strategy/LongTextPreviewStrategy.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 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 0a4570813769..21bd7a4bc050 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 @@ -47,6 +47,9 @@ public class LongTextPreviewStrategy extends AbstractTransformStrategy