From d44c1dfc99d345ea7e8811af3e3d3f441e2992ab Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:05:24 -0700 Subject: [PATCH 1/8] Add backend support for plate block --- news/1998.feature | 1 + src/plone/restapi/indexers.py | 53 +- src/plone/restapi/indexers.zcml | 4 + src/plone/restapi/tests/test_indexers.py | 644 +++++++++++++++++++++++ 4 files changed, 690 insertions(+), 12 deletions(-) create mode 100644 news/1998.feature diff --git a/news/1998.feature b/news/1998.feature new file mode 100644 index 0000000000..062bded80d --- /dev/null +++ b/news/1998.feature @@ -0,0 +1 @@ +Add support for plate block from `@kitconcept/volto-plate` (text indexer). @davisagli diff --git a/src/plone/restapi/indexers.py b/src/plone/restapi/indexers.py index 113808c4b2..b06f1536f3 100644 --- a/src/plone/restapi/indexers.py +++ b/src/plone/restapi/indexers.py @@ -2,6 +2,7 @@ from plone.indexer.decorator import indexer from plone.restapi import HAS_PLONE_6 from plone.restapi.behaviors import IBlocks +from plone.restapi.blocks import visit_blocks from plone.restapi.blocks import visit_subblocks from plone.restapi.interfaces import IBlockSearchableText from zope.component import adapter @@ -65,6 +66,41 @@ def __call__(self, block): return block.get("plaintext", "") +@implementer(IBlockSearchableText) +@adapter(IBlocks, IBrowserRequest) +class PlateTextIndexer: + """Searchable Text indexer for plate blocks.""" + + def __init__(self, context, request): + self.context = context + self.request = request + + def __call__(self, block) -> str: + texts = [self.extract_plate_text(block["value"])] + for subblock in visit_subblocks(self.context, block): + texts.append(extract_text(subblock, self.context, self.request)) + result = text_strip(texts) + print(result) + return result + + def extract_plate_text(self, value) -> str: + match value: + case list(): + return " ".join(self.extract_plate_text(item) for item in value) + case { "@type": _ }: + # sub-block, will be processed via visit_blocks + return "" + case dict(): + texts = [] + for key in ("text", "children"): + if key in value: + texts.append(self.extract_plate_text(value[key])) + return " ".join(texts) + case str(): + return value.strip() + return "" + + def extract_text(block, obj, request): """Extract text information from a block. @@ -93,23 +129,16 @@ def extract_text(block, obj, request): # Use server side adapters to extract the text data adapter = queryMultiAdapter((obj, request), IBlockSearchableText, name=block_type) result = adapter(block) if adapter is not None else "" - if not result: - for subblock in visit_subblocks(obj, block): - tmp_result = extract_text(subblock, obj, request) - result = f"{result}\n{tmp_result}" return result -def get_blocks_text(obj): +def get_blocks_text(obj) -> list[str]: """Extract text to be used by the SearchableText index in the Catalog.""" request = getRequest() - blocks = obj.blocks - blocks_layout = obj.blocks_layout - blocks_text = [] - for block_id in blocks_layout.get("items", []): - block = blocks.get(block_id, {}) - blocks_text.append(extract_text(block, obj, request)) - return blocks_text + texts = [] + for block in visit_blocks(obj, obj.blocks): + texts.append(extract_text(block, obj, request)) + return texts def text_strip(text_list): diff --git a/src/plone/restapi/indexers.zcml b/src/plone/restapi/indexers.zcml index 3d5c78ffdd..b0d74327d6 100644 --- a/src/plone/restapi/indexers.zcml +++ b/src/plone/restapi/indexers.zcml @@ -26,5 +26,9 @@ factory=".indexers.TableBlockSearchableText" name="table" /> + diff --git a/src/plone/restapi/tests/test_indexers.py b/src/plone/restapi/tests/test_indexers.py index 5774be934c..2d2ce0d2e8 100644 --- a/src/plone/restapi/tests/test_indexers.py +++ b/src/plone/restapi/tests/test_indexers.py @@ -85,6 +85,638 @@ }, } +PLATE_BLOCK = { + "@type": "__somersault__", + "value": [ + { + "blockWidth": "default", + "children": [{"text": "Wiki Page"}], + "id": "R7I1TseJfZ", + "type": "title", + }, + { + "blockWidth": "default", + "children": [ + { + "text": "Lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo. Arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel. Augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum. Blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus. Mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est." + } + ], + "id": "3Kp3XjHEfX", + "type": "p", + }, + { + "blockWidth": "default", + "children": [ + { + "text": "Quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do. Iaculis lobortis viverra lacus sagittis mauris leo porttitor. Platea fames maecenas posuere sapien interdum a do. Senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing. Suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia. elephant " + } + ], + "id": "HURTN0Xplu", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Images\n"}], + "id": "n7g-i7nUCD", + "type": "h2", + }, + { + "align": "center", + "blockWidth": "default", + "children": [{"text": ""}], + "credit": {}, + "description": "", + "id": "JpNvN48GZB", + "image_field": "image", + "image_scales": { + "image": [ + { + "content-type": "image/jpeg", + "download": "@@images/image-2400-1bb0eacaa1f242697c99f16390acb188.jpeg", + "filename": "black-starry-night.jpg", + "height": 1708, + "scales": { + "2k": { + "download": "@@images/image-2000-3392a7d7f081a956cbcc692f8883d0c1.jpeg", + "height": 1423, + "width": 2000, + }, + "great": { + "download": "@@images/image-1200-909481f59700410df495646a052dc3b1.jpeg", + "height": 854, + "width": 1200, + }, + "huge": { + "download": "@@images/image-1600-a6e6167b39388fef4b526133c1870e40.jpeg", + "height": 1138, + "width": 1600, + }, + "icon": { + "download": "@@images/image-32-9a669847a569c3298c7721e3ff2f4304.jpeg", + "height": 22, + "width": 32, + }, + "large": { + "download": "@@images/image-800-a4acb10ae66ba59e8def9bf3b71dd736.jpeg", + "height": 569, + "width": 800, + }, + "larger": { + "download": "@@images/image-1000-dc0e9da8b33ea5733e9754478d1d36b8.jpeg", + "height": 711, + "width": 1000, + }, + "mini": { + "download": "@@images/image-200-b3778482f1b185e664f87c62ebaf91b3.jpeg", + "height": 142, + "width": 200, + }, + "preview": { + "download": "@@images/image-400-e472369a7fe4b7d61a838614af838406.jpeg", + "height": 284, + "width": 400, + }, + "teaser": { + "download": "@@images/image-600-5ad744a40f6615296c5f7f4bf631e3ac.jpeg", + "height": 427, + "width": 600, + }, + "thumb": { + "download": "@@images/image-128-e22984039f956f8151d0ba444712ccbb.jpeg", + "height": 91, + "width": 128, + }, + "tile": { + "download": "@@images/image-64-7e932c9d8adfe0b8e0ad6386e264a3b9.jpeg", + "height": 45, + "width": 64, + }, + }, + "size": 693013, + "width": 2400, + } + ] + }, + "size": "l", + "styles": { + "blockWidth:noprefix": { + "--block-width": "var(--default-container-width)" + } + }, + "theme": "default", + "title": "black-starry-night.jpg", + "type": "img", + "url": "http://localhost:3000/wiki-page/black-starry-night.jpg", + }, + { + "align": "left", + "blockWidth": "default", + "children": [{"text": ""}], + "credit": {}, + "description": "", + "id": "pwOJYQ3VHm", + "image_field": "image", + "image_scales": { + "image": [ + { + "content-type": "image/png", + "download": "@@images/image-2000-f7c0b45d8fb7dacb43f858ce3b043033.png", + "filename": "plone-foundation.png", + "height": 439, + "scales": { + "2k": { + "download": "@@images/image-2000-b97cc79475c3e4b78159844401ffd5b5.png", + "height": 439, + "width": 2000, + }, + "great": { + "download": "@@images/image-1200-c79cfd78438c0219dfb6fa238bd07059.png", + "height": 263, + "width": 1200, + }, + "huge": { + "download": "@@images/image-1600-e7049d9b2a69a2c78214ae7fb23fd247.png", + "height": 351, + "width": 1600, + }, + "icon": { + "download": "@@images/image-32-eba9ceff013fd045141a03085961208e.png", + "height": 7, + "width": 32, + }, + "large": { + "download": "@@images/image-800-2baf515d696515d24f157edff7d5500c.png", + "height": 175, + "width": 800, + }, + "larger": { + "download": "@@images/image-1000-3aad43738f471d3dacadec2762146c3c.png", + "height": 219, + "width": 1000, + }, + "mini": { + "download": "@@images/image-200-a79dc1f6b295196fc4808aafaf3c1083.png", + "height": 43, + "width": 200, + }, + "preview": { + "download": "@@images/image-400-2d6d8ed83e23d421cb7c2a8d80ead210.png", + "height": 87, + "width": 400, + }, + "teaser": { + "download": "@@images/image-600-8225a92e34c5d64b17522b688e62270b.png", + "height": 131, + "width": 600, + }, + "thumb": { + "download": "@@images/image-128-2192266ed097c2df06bacaf703dfd273.png", + "height": 28, + "width": 128, + }, + "tile": { + "download": "@@images/image-64-0b0ad172a2c3a90afb779f7902dea6fc.png", + "height": 14, + "width": 64, + }, + }, + "size": 50737, + "width": 2000, + } + ] + }, + "size": "s", + "styles": { + "blockWidth:noprefix": { + "--block-width": "var(--narrow-container-width)" + }, + "size:noprefix": "small", + }, + "theme": "grey", + "title": "Plone Foundation Logo", + "type": "img", + "url": "http://localhost:3000/images/plone-foundation.png", + }, + { + "blockWidth": "default", + "children": [ + {"bold": True, "text": "Lorem ipsum"}, + {"text": " dolor sit amet odio "}, + {"italic": True, "text": "tortor"}, + { + "text": " in sollicitudin phasellus interdum justo. Arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel. Augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum. Blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus. " + }, + {"code": True, "text": "Mauris hendrerit"}, + { + "text": " facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est." + }, + ], + "id": "sZrMoBoRjY", + "type": "p", + }, + { + "blockWidth": "default", + "children": [ + { + "text": "Quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do. Iaculis lobortis viverra lacus sagittis mauris leo porttitor. Platea fames maecenas posuere sapien interdum a do. Senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing. " + }, + {"code": True, "text": "Suspendisse"}, + { + "text": " tristique gravida congue molestie mollis bibendum est ac proin lacinia." + }, + ], + "id": "i7lDvzFKId", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Heading 3"}], + "id": "6k6g1tdhjy", + "type": "h3", + }, + { + "blockWidth": "default", + "children": [ + { + "text": "Lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo. Arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel. Augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum. Blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus. Mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est." + } + ], + "id": "d6i1e5x12C", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Heading 4"}], + "id": "KCAHgIpuiP", + "type": "h4", + }, + { + "blockWidth": "default", + "children": [ + { + "text": "Lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo. Arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel. Augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum. Blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus. Mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est." + } + ], + "id": "M9-U-xW9SN", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Links"}], + "id": "jwdR0vaYZU", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [ + {"text": ""}, + { + "blockWidth": "default", + "children": [{"text": "This is a link"}], + "id": "LR0yRMwNPU", + "type": "a", + "url": "http://localhost:3000/images", + }, + {"text": ""}, + ], + "id": "IR4xX0-rK0", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Bulleted Lists"}], + "id": "_cqbTfwYnN", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "item 1"}], + "id": "K6amx9O4Qe", + "indent": 1, + "listStyleType": "disc", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "item 2"}], + "id": "Rdun1rD9a8", + "indent": 1, + "listStart": 2, + "listStyleType": "disc", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "item nested 1"}], + "id": "52XplNhNHY", + "indent": 2, + "listStyleType": "disc", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Numbered lists"}], + "id": "GkZBwjoSC2", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "item 1"}], + "id": "N0QvWn_wh4", + "indent": 1, + "listRestartPolite": 1, + "listStyleType": "decimal", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "item 2"}], + "id": "aqQGyzW-79", + "indent": 1, + "listStart": 2, + "listStyleType": "decimal", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "item nested 1"}], + "id": "moU8i2MrAo", + "indent": 2, + "listStyleType": "decimal", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "ToDo list"}], + "id": "wZk0XsUILT", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "ToDo list item 1"}], + "id": "Z3bev6Y3x-", + "indent": 1, + "listStyleType": "todo", + "type": "p", + }, + { + "blockWidth": "default", + "checked": True, + "children": [{"text": "ToDo list item 2"}], + "id": "r8HwnJkANA", + "indent": 1, + "listStart": 2, + "listStyleType": "todo", + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Toggle items (collapsibles)"}], + "id": "MsbUIn1l-e", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "This is a toggle item"}], + "id": "ZtFH1V5VbH", + "type": "toggle", + }, + { + "blockWidth": "default", + "children": [{"text": "With collapsible text inside"}], + "id": "zBMhIVCfyG", + "indent": 1, + "type": "p", + }, + { + "blockWidth": "default", + "children": [{"text": "Code"}], + "id": "LID0KLs_8q", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "from plone import api"}], + "id": "4x8ZCzV4_C", + "type": "code_line", + }, + { + "blockWidth": "default", + "children": [{"text": ""}], + "id": "zpN-hqZwsi", + "type": "code_line", + }, + { + "blockWidth": "default", + "children": [ + {"text": 'brains = api.content.find(portal_type="WikiPage")'} + ], + "id": "iKrnnE152j", + "type": "code_line", + }, + { + "blockWidth": "default", + "children": [{"text": "for brain in brains:"}], + "id": "msc_Df5asY", + "type": "code_line", + }, + { + "blockWidth": "default", + "children": [{"text": " print(brain.Title)"}], + "id": "8_U5v15Wv_", + "type": "code_line", + }, + { + "blockWidth": "default", + "children": [{"text": " "}], + "id": "oEGjq9wRfX", + "type": "code_line", + }, + ], + "id": "1HtHs7cBq8", + "lang": "python", + "type": "code_block", + }, + { + "blockWidth": "default", + "children": [{"text": "Tables"}], + "id": "-SiubNCFsU", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "Header 1"}], + "id": "XPZB9k7A_O", + "type": "p", + } + ], + "id": "34zB7tsQuA", + "type": "td", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "Header 2"}], + "id": "sUuGGPqaCe", + "type": "p", + } + ], + "id": "S1z5N7hRtV", + "type": "td", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "different widths"}], + "id": "CT-5hB1BeT", + "type": "p", + } + ], + "colSpan": 1, + "id": "rqwSe1ZNIi", + "rowSpan": 1, + "type": "td", + }, + { + "blockWidth": "default", + "borders": {"right": {"size": 0}, "top": {"size": 0}}, + "children": [ + { + "blockWidth": "default", + "children": [{"text": "and borders"}], + "id": "YLR0BX6IWm", + "type": "p", + } + ], + "colSpan": 1, + "id": "BNF3uHQp7_", + "rowSpan": 1, + "type": "td", + }, + ], + "id": "NWEyO-n86Q", + "type": "tr", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "Merged cells"}], + "id": "Xk7ZIMyGld", + "type": "p", + } + ], + "colSpan": 2, + "id": "7b0R3wmddK", + "rowSpan": 1, + "type": "td", + }, + { + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": ""}], + "id": "iDfGbSKjTQ", + "type": "p", + } + ], + "colSpan": 1, + "id": "kTT7k0XoPZ", + "rowSpan": 1, + "type": "td", + }, + { + "background": "#4B85E8", + "blockWidth": "default", + "children": [ + { + "blockWidth": "default", + "children": [{"text": "and backgrounds"}], + "id": "XxSuKW1hZ3", + "type": "p", + } + ], + "colSpan": 1, + "id": "t612mnrE7y", + "rowSpan": 1, + "type": "td", + }, + ], + "id": "Uj6_EOM1Wp", + "type": "tr", + }, + ], + "colSizes": [0, 0, 470, 0], + "id": "2Yz2b5CMm0", + "type": "table", + }, + { + "blockWidth": "default", + "children": [{"text": "Blockquote"}], + "id": "ABs91fnktj", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "This is a blockquote"}], + "id": "vjfZbY4TJi", + "type": "blockquote", + }, + { + "blockWidth": "default", + "children": [{"text": "Callouts"}], + "id": "1z77VtReID", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": "This is a callout"}], + "icon": "\ud83d\udca1", + "id": "AbGgC_GM2l", + "type": "callout", + }, + { + "blockWidth": "default", + "children": [{"text": "Table of contents"}], + "id": "Ixkw1BK8mD", + "type": "h2", + }, + { + "blockWidth": "default", + "children": [{"text": ""}], + "id": "8mxTgibI62", + "type": "toc", + }, + { + "blockWidth": "default", + "children": [{"text": ""}], + "id": "1WXuAaHdQ3", + "type": "p", + }, + ], +} + class TestSearchableTextIndexer(unittest.TestCase): @@ -216,3 +848,15 @@ def test_indexer_block_with_subblocks(self): self.assertIn("plone is a powerful content management system", result) # From Table block self.assertIn("my data", result) + + def test_indexer_block_plate(self): + document = self.document + document.blocks = { + "__somersault__": PLATE_BLOCK, + } + document.reindexObject() + result = self._extract_searchable_text(document) + self.assertEqual( + result, + "title is here description is there wiki page lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia elephant images lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia heading 3 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est heading 4 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est links this is a link bulleted lists item 1 item 2 item nested 1 numbered lists item 1 item 2 item nested 1 todo list todo list item 1 todo list item 2 toggle items collapsibles this is a toggle item with collapsible text inside code from plone import api brains api content find portal_type wikipage for brain in brains print brain title tables header 1 header 2 different widths and borders merged cells and backgrounds blockquote this is a blockquote callouts this is a callout table of contents", + ) From 121e3653aef13d8b290b18427a3f9cedd354fc52 Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:11:15 -0700 Subject: [PATCH 2/8] Avoid match statement to support older Pythons :( --- src/plone/restapi/indexers.py | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/plone/restapi/indexers.py b/src/plone/restapi/indexers.py index b06f1536f3..8bd099ff69 100644 --- a/src/plone/restapi/indexers.py +++ b/src/plone/restapi/indexers.py @@ -84,20 +84,19 @@ def __call__(self, block) -> str: return result def extract_plate_text(self, value) -> str: - match value: - case list(): - return " ".join(self.extract_plate_text(item) for item in value) - case { "@type": _ }: + if isinstance(value, list): + return " ".join(self.extract_plate_text(item) for item in value) + elif isinstance(value, dict): + if "@type" in value: # sub-block, will be processed via visit_blocks return "" - case dict(): - texts = [] - for key in ("text", "children"): - if key in value: - texts.append(self.extract_plate_text(value[key])) - return " ".join(texts) - case str(): - return value.strip() + texts = [] + for key in ("text", "children"): + if key in value: + texts.append(self.extract_plate_text(value[key])) + return " ".join(texts) + elif isinstance(value, str): + return value.strip() return "" From 8918d9e081f776f474813637aeb2e63ca8ba5be6 Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:27:54 -0700 Subject: [PATCH 3/8] Support linkintegrity for plate block --- news/1998.feature | 2 +- src/plone/restapi/blocks_linkintegrity.py | 26 +++++++++++---- src/plone/restapi/configure.zcml | 4 +++ .../tests/test_blocks_linkintegrity.py | 33 +++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/news/1998.feature b/news/1998.feature index 062bded80d..a43c397134 100644 --- a/news/1998.feature +++ b/news/1998.feature @@ -1 +1 @@ -Add support for plate block from `@kitconcept/volto-plate` (text indexer). @davisagli +Add support for plate block from `@kitconcept/volto-plate` (text indexer, link integrity). @davisagli diff --git a/src/plone/restapi/blocks_linkintegrity.py b/src/plone/restapi/blocks_linkintegrity.py index ffcc57c590..d8ccbbfbbf 100644 --- a/src/plone/restapi/blocks_linkintegrity.py +++ b/src/plone/restapi/blocks_linkintegrity.py @@ -54,11 +54,7 @@ def __call__(self, block): return links -@adapter(IBlocks, IBrowserRequest) -@implementer(IBlockFieldLinkIntegrityRetriever) -class SlateBlockLinksRetriever: - order = 100 - block_type = "slate" +class BaseSlateOrPlateBlockLinksRetriever: field = "value" def __init__(self, context, request): @@ -77,9 +73,16 @@ def __call__(self, block): value = handler(child) if value: self.links.append(value) - return self.links + +@adapter(IBlocks, IBrowserRequest) +@implementer(IBlockFieldLinkIntegrityRetriever) +class SlateBlockLinksRetriever(BaseSlateOrPlateBlockLinksRetriever): + order = 100 + block_type = "slate" + field = "value" + def handle_a(self, child): data = child.get("data", {}) if data.get("link", {}).get("internal", {}).get("internal_link"): @@ -92,6 +95,17 @@ def handle_link(self, child): return child["data"]["url"] +@adapter(IBlocks, IBrowserRequest) +@implementer(IBlockFieldLinkIntegrityRetriever) +class PlateBlockLinksRetriever(BaseSlateOrPlateBlockLinksRetriever): + order = 100 + block_type = "__somersault__" + field = "value" + + def handle_a(self, child): + return child.get("url") + + @adapter(IBlocks, IBrowserRequest) @implementer(IBlockFieldLinkIntegrityRetriever) class GenericBlockLinksRetriever: diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 6d6ab0bace..32e0762130 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -151,5 +151,9 @@ factory=".blocks_linkintegrity.SlateBlockLinksRetriever" provides="plone.restapi.interfaces.IBlockFieldLinkIntegrityRetriever" /> + diff --git a/src/plone/restapi/tests/test_blocks_linkintegrity.py b/src/plone/restapi/tests/test_blocks_linkintegrity.py index c89df46cda..3190de556c 100644 --- a/src/plone/restapi/tests/test_blocks_linkintegrity.py +++ b/src/plone/restapi/tests/test_blocks_linkintegrity.py @@ -248,6 +248,39 @@ def test_links_retriever_skip_empty_links(self): self.assertEqual(len(value), 0) + def test_links_retriever_return_internal_links_type_a_in_plate_block(self): + uid = IUUID(self.doc2) + resolve_uid_link = f"../resolveuid/{uid}" + blocks = { + "__somersault__": { + "@type": "__somersault__", + "value": [ + { + "blockWidth": "default", + "children": [ + {"text": ""}, + { + "blockWidth": "default", + "children": [{"text": "This is a link"}], + "id": "LR0yRMwNPU", + "type": "a", + "url": resolve_uid_link, + }, + {"text": ""}, + ], + "id": "IR4xX0-rK0", + "type": "p", + }, + ], + }, + } + + self.portal.doc1.blocks = blocks + value = self.retrieve_links(blocks) + + self.assertEqual(len(value), 1) + self.assertIn(resolve_uid_link, value) + class TestLinkintegrityForBlocks(TestCase): From 0c8737a084dc9138c6d8795019df50aeec31b8a2 Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:30:07 -0700 Subject: [PATCH 4/8] fix --- src/plone/restapi/indexers.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/plone/restapi/indexers.py b/src/plone/restapi/indexers.py index 8bd099ff69..817488435b 100644 --- a/src/plone/restapi/indexers.py +++ b/src/plone/restapi/indexers.py @@ -5,6 +5,7 @@ from plone.restapi.blocks import visit_blocks from plone.restapi.blocks import visit_subblocks from plone.restapi.interfaces import IBlockSearchableText +from typing import List from zope.component import adapter from zope.component import queryMultiAdapter from zope.globalrequest import getRequest @@ -131,7 +132,7 @@ def extract_text(block, obj, request): return result -def get_blocks_text(obj) -> list[str]: +def get_blocks_text(obj) -> List[str]: """Extract text to be used by the SearchableText index in the Catalog.""" request = getRequest() texts = [] From 3aca0a4479017f27193380f1c77afbcd917109de Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:38:45 -0700 Subject: [PATCH 5/8] Add tests for resolveuid transforms with plate block --- news/1998.feature | 2 +- .../restapi/tests/test_blocks_deserializer.py | 30 ++++++++++++++++ .../restapi/tests/test_blocks_serializer.py | 35 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/news/1998.feature b/news/1998.feature index a43c397134..4050ce187e 100644 --- a/news/1998.feature +++ b/news/1998.feature @@ -1 +1 @@ -Add support for plate block from `@kitconcept/volto-plate` (text indexer, link integrity). @davisagli +Add support for plate block from `@kitconcept/volto-plate` (text indexer, resolveuid transforms, link integrity). @davisagli diff --git a/src/plone/restapi/tests/test_blocks_deserializer.py b/src/plone/restapi/tests/test_blocks_deserializer.py index dcaf3bf2c8..047c7b32d2 100644 --- a/src/plone/restapi/tests/test_blocks_deserializer.py +++ b/src/plone/restapi/tests/test_blocks_deserializer.py @@ -738,3 +738,33 @@ def test_deserializer_resolve_path_also_if_it_is_an_alias(self): res = self.deserialize(blocks=blocks) link = res.blocks["abc"]["href"] self.assertEqual(link, f"../resolveuid/{self.portal['renamed-doc'].UID()}") + + def test_plate_internal_link_deserializer(self): + target_url = self.image.absolute_url() + blocks = { + "__somersault__": { + "@type": "__somersault__", + "value": [ + { + "blockWidth": "default", + "children": [ + {"text": ""}, + { + "blockWidth": "default", + "children": [{"text": "This is a link"}], + "id": "LR0yRMwNPU", + "type": "a", + "url": target_url, + }, + {"text": ""}, + ], + "id": "IR4xX0-rK0", + "type": "p", + }, + ], + }, + } + res = self.deserialize(blocks=blocks) + value = res.blocks["__somersault__"]["value"] + link = value[0]["children"][1]["url"] + self.assertTrue(link.startswith("../resolveuid/")) diff --git a/src/plone/restapi/tests/test_blocks_serializer.py b/src/plone/restapi/tests/test_blocks_serializer.py index a12971af08..3fd8080298 100644 --- a/src/plone/restapi/tests/test_blocks_serializer.py +++ b/src/plone/restapi/tests/test_blocks_serializer.py @@ -637,3 +637,38 @@ def test_teaser_block_serializer_legacy(self): self.assertEqual(block["description"], "Custom description") href = block["href"][0] self.assertEqual(href["@id"], doc.absolute_url()) + + def test_plate_internal_link_serializer(self): + target_item = self.image + resolveuid_link = f"../resolveuid/{target_item.UID()}" + blocks = { + "__somersault__": { + "@type": "__somersault__", + "value": [ + { + "blockWidth": "default", + "children": [ + {"text": ""}, + { + "blockWidth": "default", + "children": [{"text": "This is a link"}], + "id": "LR0yRMwNPU", + "type": "a", + "url": resolveuid_link, + }, + {"text": ""}, + ], + "id": "IR4xX0-rK0", + "type": "p", + }, + ], + }, + } + + res = self.serialize( + context=self.portal["doc1"], + blocks=blocks, + ) + value = res["__somersault__"]["value"] + link = value[0]["children"][1]["url"] + self.assertTrue(link == target_item.absolute_url()) From cb55b8b65f01dbaac7cae40b9982ccd1d8d9ba2b Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:42:45 -0700 Subject: [PATCH 6/8] Make order of extracted text consistent in Plone 5 --- src/plone/restapi/indexers.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/plone/restapi/indexers.py b/src/plone/restapi/indexers.py index 817488435b..206caa1108 100644 --- a/src/plone/restapi/indexers.py +++ b/src/plone/restapi/indexers.py @@ -168,5 +168,4 @@ def SearchableText_blocks(obj): blocks_text = get_blocks_text(obj) # Extract text using the base plone.app.contenttypes indexer std_text = SearchableText(obj) - blocks_text.append(std_text) - return text_strip(blocks_text) + return text_strip([std_text] + blocks_text) From b38d082b043a7660a4dea1ccf865a5befe3b10e8 Mon Sep 17 00:00:00 2001 From: David Glick Date: Thu, 9 Apr 2026 15:47:46 -0700 Subject: [PATCH 7/8] fix --- src/plone/restapi/tests/test_indexers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/plone/restapi/tests/test_indexers.py b/src/plone/restapi/tests/test_indexers.py index 2d2ce0d2e8..0c746f7135 100644 --- a/src/plone/restapi/tests/test_indexers.py +++ b/src/plone/restapi/tests/test_indexers.py @@ -856,7 +856,7 @@ def test_indexer_block_plate(self): } document.reindexObject() result = self._extract_searchable_text(document) - self.assertEqual( + self.assertIn( + "wiki page lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia elephant images lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia heading 3 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est heading 4 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est links this is a link bulleted lists item 1 item 2 item nested 1 numbered lists item 1 item 2 item nested 1 todo list todo list item 1 todo list item 2 toggle items collapsibles this is a toggle item with collapsible text inside code from plone import api brains api content find portal_type wikipage for brain in brains print brain title tables header 1 header 2 different widths and borders merged cells and backgrounds blockquote this is a blockquote callouts this is a callout table of contents", result, - "title is here description is there wiki page lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia elephant images lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est quam luctus pulvinar urna egestas cras nisi eget sollicitudin aliquet sodales do iaculis lobortis viverra lacus sagittis mauris leo porttitor platea fames maecenas posuere sapien interdum a do senectus adipiscing senectus aliqua porta nec quam non tempor adipiscing suspendisse tristique gravida congue molestie mollis bibendum est ac proin lacinia heading 3 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est heading 4 lorem ipsum dolor sit amet odio tortor in sollicitudin phasellus interdum justo arcu hendrerit pretium nisi praesent tempus netus non neque blandit vel augue molestie cras fames vestibulum convallis facilisi hac imperdiet fermentum blandit phasellus nullam auctor elit enim eu mollis aliquet adipiscing feugiat dui lacus luctus mauris hendrerit facilisis est nunc turpis pharetra eleifend duis porta platea nulla tortor aliquam est links this is a link bulleted lists item 1 item 2 item nested 1 numbered lists item 1 item 2 item nested 1 todo list todo list item 1 todo list item 2 toggle items collapsibles this is a toggle item with collapsible text inside code from plone import api brains api content find portal_type wikipage for brain in brains print brain title tables header 1 header 2 different widths and borders merged cells and backgrounds blockquote this is a blockquote callouts this is a callout table of contents", ) From 5f534c46b1e7362deef27f195eea7ad6222c06f8 Mon Sep 17 00:00:00 2001 From: David Glick Date: Mon, 13 Apr 2026 11:08:14 -0700 Subject: [PATCH 8/8] visit blocks inside plate value --- src/plone/restapi/blocks.py | 13 +++++++++++++ src/plone/restapi/tests/test_blocks.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/plone/restapi/blocks.py b/src/plone/restapi/blocks.py index 1f927a9d81..2809f82fca 100644 --- a/src/plone/restapi/blocks.py +++ b/src/plone/restapi/blocks.py @@ -1,3 +1,4 @@ +from collections import deque from plone.restapi.interfaces import IBlockVisitor from zope.component import adapter from zope.component import subscribers @@ -72,3 +73,15 @@ def __call__(self, block_value): yield from block_value["data"]["blocks"].values() if "blocks" in block_value: yield from block_value["blocks"].values() + if block_value.get("@type") == "__somersault__": + yield from self.visit_plate_value(block_value.get("value", [])) + + def visit_plate_value(self, value): + queue = deque(value) + while queue: + child = queue.pop() + if isinstance(child, dict): + if "@type" in child: + yield child + elif child.get("children", []): + queue.extend(child["children"] or []) diff --git a/src/plone/restapi/tests/test_blocks.py b/src/plone/restapi/tests/test_blocks.py index 935c096f70..c3e6b0ebd8 100644 --- a/src/plone/restapi/tests/test_blocks.py +++ b/src/plone/restapi/tests/test_blocks.py @@ -38,3 +38,21 @@ def test_visit_blocks(self): visited.append(block["@id"]) # depth-first traversal self.assertEqual(visited, ["block2", "block1"]) + + def test_visit_blocks_in_plate_value(self): + visited = [] + blocks = { + "__somersault__": { + "@type": "__somersault__", + "value": [ + { + "type": "p", + "children": [{"@type": "image"}], + } + ], + } + } + for block in visit_blocks(self.doc, blocks): + visited.append(block["@type"]) + # depth-first traversal + self.assertEqual(visited, ["image", "__somersault__"])