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/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/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..21bd7a4bc050 --- /dev/null +++ b/dotCMS/src/main/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategy.java @@ -0,0 +1,180 @@ +package com.dotmarketing.portlets.contentlet.transform.strategy; + +import com.dotcms.api.APIProvider; +import com.dotcms.contenttype.model.field.Field; +import com.dotcms.contenttype.model.field.StoryBlockField; +import com.dotcms.contenttype.model.field.TextAreaField; +import com.dotcms.contenttype.model.field.WysiwygField; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.repackage.org.jsoup.Jsoup; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import static com.dotmarketing.portlets.contentlet.model.Contentlet.TITTLE_KEY; +import com.dotmarketing.util.Logger; +import com.dotmarketing.util.UtilMethods; +import com.liferay.portal.model.User; +import com.liferay.util.StringPool; +import io.vavr.control.Try; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +/** + * Replaces WYSIWYG, TextArea and Story Block field values in a transformed map with a + * <=150-character extracted plain-text preview, instead of the raw stored value. + *

+ * 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; + + /** + * Upper bound, in characters, on how much of a raw HTML value is handed to {@link Jsoup#parse} + * before flattening to text and truncating. {@code MAX_PREVIEW_LENGTH} characters of visible + * text fit inside this budget with wide margin even for markup-heavy bodies, so a full-body + * parse is never needed just to keep a 150-character preview (found in review). + */ + private static final int HTML_PARSE_BUDGET = 4096; + + /** Appended to a preview when truncation actually drops content (found in review). */ + private static final String TRUNCATION_MARKER = "…"; + + 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.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())) + // 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( + "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; + } + final String html = (String) rawValue; + final String bounded = html.length() > HTML_PARSE_BUDGET + ? html.substring(0, HTML_PARSE_BUDGET) : html; + return truncate(Jsoup.parse(bounded).text()); + } + + /** + * Story Block: the map already holds {@link StoryBlockViewStrategy}'s output -- a + * {@link Map} (parsed JSON), a raw {@link String} (non-JSON fallback) or {@code null} + * (parse-failure fallback). Extract and truncate text from whichever shape it is. + */ + private static String extractStoryBlockPreview(final Object storyBlockValue) { + if (null == storyBlockValue) { + return StringPool.BLANK; + } + if (storyBlockValue instanceof String) { + return truncate((String) storyBlockValue); + } + final StringBuilder textBuilder = new StringBuilder(); + collectText(storyBlockValue, textBuilder); + return truncate(textBuilder.toString()); + } + + /** + * Recursively walks a Story Block JSON-tree node, collecting every {@code text} leaf value. + * Stops once enough text has been collected for the preview bound, so a large story block is + * not fully traversed/concatenated just to be truncated away afterward (found in review). + */ + private static void collectText(final Object node, final StringBuilder out) { + if (out.length() >= MAX_PREVIEW_LENGTH) { + return; + } + if (node instanceof Map) { + final Map nodeMap = (Map) node; + final Object text = nodeMap.get("text"); + if (text instanceof String) { + if (out.length() > 0) { + out.append(' '); + } + out.append((String) text); + } + final Object content = nodeMap.get("content"); + if (content instanceof List) { + for (final Object child : (List) content) { + if (out.length() >= MAX_PREVIEW_LENGTH) { + break; + } + collectText(child, out); + } + } + } else if (node instanceof List) { + for (final Object child : (List) node) { + if (out.length() >= MAX_PREVIEW_LENGTH) { + break; + } + collectText(child, out); + } + } + } + + private static String truncate(final String text) { + if (null == text) { + return StringPool.BLANK; + } + if (text.length() <= MAX_PREVIEW_LENGTH) { + return text; + } + // Leave room for the truncation marker so the total visible length still honors the + // <=150-character bound from AC-001. + final int budget = MAX_PREVIEW_LENGTH - TRUNCATION_MARKER.length(); + // 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(budget - 1)) + ? budget - 1 : budget; + // A hard cut is indistinguishable from a short, complete value -- append the marker + // exactly when content was actually dropped, so its presence signals truncation + // (found in review). + return text.substring(0, cutIndex) + TRUNCATION_MARKER; + } + +} 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 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..a28110e469e5 --- /dev/null +++ b/dotCMS/src/test/java/com/dotmarketing/portlets/contentlet/transform/strategy/LongTextPreviewStrategyTest.java @@ -0,0 +1,326 @@ +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")); + } + + /** + * 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 ---------------------------------------------- + + /** + * {@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() 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", + 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)); + } +} 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..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,8 +7,13 @@ 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; +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; @@ -70,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; @@ -2531,4 +2537,293 @@ 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, 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", "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()); + } + } + + /** + * 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)}
  • + *
  • 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 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, rawStoryBlockJson) + .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); + assertPayloadSizeDropsByAtLeastHalf(row, rawHtmlBody, rawStoryBlockJson); + } + + /** + *
    + *
  • 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 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, rawStoryBlockJson) + .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); + assertPayloadSizeDropsByAtLeastHalf(row, rawHtmlBody, rawStoryBlockJson); + } + + /** + *
    + *
  • 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(); + + // 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) + .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..dac705585c67 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,18 @@ " var jsonData = pm.response.json();", " var list = jsonData.entity.list;", " ", - " // Should not include items without 'Alpha' when filtering", - " var allItemsMatch = list.every(item => {", + " // 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). 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 || '';", - " 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(hasMatchingItem).to.be.true;", " pm.expect(list.length).to.be.at.most(10); // Should be filtered down", "});" ],