1414"""
1515
1616from AccessControl import ClassSecurityInfo
17+ from App .special_dtml import DTMLFile
1718from contextlib import contextmanager
19+ from plone .pgcatalog .backends import BM25Backend
1820from plone .pgcatalog .backends import get_backend
1921from plone .pgcatalog .brain import CatalogSearchResults
2022from plone .pgcatalog .brain import PGCatalogBrain
@@ -269,6 +271,37 @@ class PlonePGCatalogTool(CatalogTool):
269271 meta_type = "PG Catalog Tool"
270272 security = ClassSecurityInfo ()
271273
274+ # Replace irrelevant ZCatalog tabs with PG-aware versions:
275+ # - Remove: Query Report, Query Plan (BTree timing), Indexes, Metadata
276+ # - Replace Indexes + Metadata with merged "Indexes & Metadata" tab
277+ manage_options = (
278+ * (
279+ tab
280+ for tab in CatalogTool .manage_options
281+ if tab ["action" ]
282+ not in (
283+ "manage_catalogReport" ,
284+ "manage_catalogPlan" ,
285+ "manage_catalogIndexes" ,
286+ "manage_catalogSchema" ,
287+ )
288+ ),
289+ {"action" : "manage_catalogIndexesAndMetadata" , "label" : "Indexes & Metadata" },
290+ )
291+
292+ # Simplified Advanced tab: only Update Catalog and Clear and Rebuild.
293+ # ZCatalog's subtransactions and progress logging don't apply to PG.
294+ manage_catalogAdvanced = DTMLFile ("www/catalogAdvanced" , globals ())
295+
296+ # PG-backed Catalog tab and object detail view.
297+ manage_catalogView = DTMLFile ("www/catalogView" , globals ())
298+ manage_objectInformation = DTMLFile ("www/catalogObjectInformation" , globals ())
299+
300+ # Merged Indexes & Metadata tab.
301+ manage_catalogIndexesAndMetadata = DTMLFile (
302+ "www/catalogIndexesAndMetadata" , globals ()
303+ )
304+
272305 # Override ZCatalog's Indexes container to wrap each index with
273306 # PGIndex — provides PG-backed _index and uniqueValues().
274307 Indexes = PGCatalogIndexes ()
@@ -277,6 +310,15 @@ class PlonePGCatalogTool(CatalogTool):
277310 security .declareProtected ("Manage ZCatalog Entries" , "refreshCatalog" )
278311 security .declareProtected ("Manage ZCatalog Entries" , "reindexIndex" )
279312 security .declareProtected ("Manage ZCatalog Entries" , "clearFindAndRebuild" )
313+ security .declareProtected ("Manage ZCatalog Entries" , "manage_get_catalog_summary" )
314+ security .declareProtected ("Manage ZCatalog Entries" , "manage_get_catalog_objects" )
315+ security .declareProtected ("Manage ZCatalog Entries" , "manage_get_object_detail" )
316+ security .declareProtected (
317+ "Manage ZCatalog Entries" , "manage_get_indexes_and_metadata"
318+ )
319+ security .declareProtected (
320+ "Manage ZCatalog Entries" , "manage_catalogIndexesAndMetadata"
321+ )
280322
281323 @contextmanager
282324 def _pg_connection (self ):
@@ -706,6 +748,139 @@ def getrid(self, path, default=None):
706748 row = cur .fetchone ()
707749 return row ["zoid" ] if row else default
708750
751+ # -- ZMI helpers ---------------------------------------------------------
752+
753+ _ZMI_PAGE_SIZE = 20
754+
755+ def manage_get_catalog_summary (self ):
756+ """Return summary dict for the Catalog tab header."""
757+ conn = self ._get_pg_read_connection ()
758+ with conn .cursor () as cur :
759+ cur .execute (
760+ "SELECT COUNT(*) AS cnt FROM object_state WHERE idx IS NOT NULL"
761+ )
762+ row = cur .fetchone ()
763+ object_count = row ["cnt" ] if row else 0
764+
765+ registry = get_registry ()
766+ backend = get_backend ()
767+ has_bm25 = isinstance (backend , BM25Backend )
768+ return {
769+ "object_count" : object_count ,
770+ "index_count" : len (registry ),
771+ "metadata_count" : len (registry .metadata ),
772+ "backend_name" : "BM25" if has_bm25 else "Tsvector" ,
773+ "has_bm25" : has_bm25 ,
774+ "bm25_languages" : list (getattr (backend , "languages" , [])),
775+ }
776+
777+ def manage_get_catalog_objects (self , batch_start = 0 , filterpath = "" ):
778+ """Return paginated list of cataloged objects for the Catalog tab."""
779+ batch_start = int (batch_start )
780+ conn = self ._get_pg_read_connection ()
781+
782+ params = {}
783+ where = "idx IS NOT NULL"
784+ if filterpath :
785+ where += " AND path LIKE %(prefix)s"
786+ # Escape LIKE wildcards in user input, then add trailing %
787+ safe = (
788+ filterpath .replace ("\\ " , "\\ \\ " ).replace ("%" , "\\ %" ).replace ("_" , "\\ _" )
789+ )
790+ params ["prefix" ] = safe + "%"
791+
792+ sql = (
793+ "SELECT zoid, path, idx->>'portal_type' AS portal_type, "
794+ "COUNT(*) OVER() AS _total "
795+ f"FROM object_state WHERE { where } "
796+ "ORDER BY path "
797+ f"LIMIT { self ._ZMI_PAGE_SIZE } OFFSET %(offset)s"
798+ )
799+ params ["offset" ] = batch_start
800+
801+ with conn .cursor () as cur :
802+ cur .execute (sql , params )
803+ rows = cur .fetchall ()
804+
805+ total = rows [0 ]["_total" ] if rows else 0
806+ objects = [
807+ {
808+ "zoid" : r ["zoid" ],
809+ "path" : r ["path" ],
810+ "portal_type" : r ["portal_type" ] or "" ,
811+ }
812+ for r in rows
813+ ]
814+ return {"objects" : objects , "total" : total , "batch_start" : batch_start }
815+
816+ def manage_get_object_detail (self , zoid ):
817+ """Return detail dict for a single cataloged object.
818+
819+ Returns idx_items as a pre-sorted list of {"key", "value"} dicts
820+ so the DTML template doesn't need isinstance/sorted (restricted).
821+ """
822+ zoid = int (zoid )
823+ conn = self ._get_pg_read_connection ()
824+ with conn .cursor () as cur :
825+ cur .execute (
826+ "SELECT path, idx, "
827+ "searchable_text IS NOT NULL AS has_searchable_text, "
828+ "left(searchable_text::text, 200) AS searchable_text_preview "
829+ "FROM object_state WHERE zoid = %(zoid)s" ,
830+ {"zoid" : zoid },
831+ )
832+ row = cur .fetchone ()
833+ if row is None :
834+ return None
835+ idx = row ["idx" ] or {}
836+ idx_items = []
837+ for k in sorted (idx ):
838+ v = idx [k ]
839+ if isinstance (v , list ):
840+ display = ", " .join (str (i ) for i in v )
841+ elif v is None :
842+ display = ""
843+ elif isinstance (v , bool ):
844+ display = "True" if v else "False"
845+ else :
846+ display = str (v )
847+ idx_items .append ({"key" : k , "value" : display , "is_none" : v is None })
848+ return {
849+ "path" : row ["path" ],
850+ "idx_items" : idx_items ,
851+ "has_searchable_text" : row ["has_searchable_text" ],
852+ "searchable_text_preview" : row ["searchable_text_preview" ] or "" ,
853+ }
854+
855+ def manage_get_indexes_and_metadata (self ):
856+ """Return indexes and metadata from the IndexRegistry for ZMI display.
857+
858+ Returns a dict with:
859+ - indexes: sorted list of dicts with name, index_type, storage info
860+ - metadata: sorted list of metadata column names
861+ - index_count / metadata_count: totals
862+ """
863+ registry = get_registry ()
864+ indexes = []
865+ for name , (idx_type , idx_key , source_attrs ) in sorted (registry .items ()):
866+ indexes .append (
867+ {
868+ "name" : name ,
869+ "index_type" : idx_type .value ,
870+ "idx_key" : idx_key or "" ,
871+ "is_special" : idx_key is None ,
872+ "storage" : "dedicated column" if idx_key is None else "idx JSONB" ,
873+ "source_attrs" : ", " .join (source_attrs ),
874+ }
875+ )
876+ metadata = sorted (registry .metadata )
877+ return {
878+ "indexes" : indexes ,
879+ "metadata" : metadata ,
880+ "index_count" : len (indexes ),
881+ "metadata_count" : len (metadata ),
882+ }
883+
709884 # -- Helpers -------------------------------------------------------------
710885
711886 def _wrap_object (self , obj ):
0 commit comments