Skip to content

Commit 1ff3ff6

Browse files
jensensclaude
andcommitted
ZMI polish: PG-backed Catalog, Advanced, and Indexes & Metadata tabs
Replace inherited ZCatalog BTree-based ZMI views with PG-aware versions: - Catalog tab: summary (object count, backend status), paginated object table with path filter, object detail with idx JSONB and searchable text - Advanced tab: simplified to Update Catalog and Clear and Rebuild - Indexes & Metadata tab: merged view reflecting IndexRegistry (replaces two tabs that showed always-zero BTree counts) - Lexicon cleanup: remove orphaned ZCTextIndex lexicons at install time Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f12d864 commit 1ff3ff6

9 files changed

Lines changed: 902 additions & 3 deletions

CHANGES.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,39 @@
11
# Changelog
22

3+
## 1.0.0b9
4+
5+
### Changed
6+
7+
- **ZMI polish**: All ZMI tabs now use Bootstrap 4 cards/tables matching
8+
Zope 5's modern look (was old-style `<table>` layout with `section-bar`).
9+
10+
- **Catalog tab** (`manage_catalogView`): Replaced inherited ZCatalog
11+
BTree-based view with PG-backed version. Shows catalog summary (object
12+
count, index/metadata count, search backend with BM25/Tsvector status),
13+
path filter, and server-side paginated object table (20/page) with
14+
Previous/Next navigation. Object detail shows full idx JSONB and
15+
searchable text preview.
16+
17+
- **Advanced tab** (`manage_catalogAdvanced`): Simplified to only show
18+
Update Catalog and Clear and Rebuild actions. Removed ZCatalog-specific
19+
features (subtransactions, progress logging, standalone Clear Catalog)
20+
that don't apply to PostgreSQL.
21+
22+
- **Indexes & Metadata tab** (`manage_catalogIndexesAndMetadata`): Merged
23+
the separate Indexes and Metadata tabs into one read-only view showing
24+
all registered indexes (name, type, PG storage location, source attrs)
25+
and metadata columns. Reflects the IndexRegistry rather than BTree
26+
counts (which were always 0).
27+
28+
- **Removed tabs**: Query Report, Query Plan (BTree timing), and the
29+
separate Indexes / Metadata tabs are hidden — replaced by PG-aware
30+
equivalents.
31+
32+
- **Lexicon cleanup**: `setuphandlers.install()` now removes orphaned
33+
ZCTextIndex lexicons (`htmltext_lexicon`, `plaintext_lexicon`,
34+
`plone_lexicon`) created by Plone's `catalog.xml` — unused with
35+
PG-backed text search.
36+
337
## 1.0.0b8
438

539
### Changed

src/plone/pgcatalog/catalog.py

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
"""
1515

1616
from AccessControl import ClassSecurityInfo
17+
from App.special_dtml import DTMLFile
1718
from contextlib import contextmanager
19+
from plone.pgcatalog.backends import BM25Backend
1820
from plone.pgcatalog.backends import get_backend
1921
from plone.pgcatalog.brain import CatalogSearchResults
2022
from 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):

src/plone/pgcatalog/setuphandlers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ def install(context):
2626

2727
site = context.getSite()
2828
_ensure_catalog_indexes(site)
29+
_remove_lexicons(site)
2930

3031

3132
def _ensure_catalog_indexes(site):
@@ -74,3 +75,20 @@ def _ensure_catalog_indexes(site):
7475
log.info("Re-applied catalog config from %s", profile_id)
7576
except Exception:
7677
log.debug("Could not apply catalog from %s", profile_id, exc_info=True)
78+
79+
80+
def _remove_lexicons(site):
81+
"""Remove ZCTextIndex lexicons — unused with PG-backed text search.
82+
83+
Plone's catalog.xml creates htmltext_lexicon, plaintext_lexicon, and
84+
plone_lexicon for ZCTextIndex stemming/splitting. With pgcatalog,
85+
full-text search uses PostgreSQL tsvector/BM25, so these are orphaned.
86+
"""
87+
catalog = getattr(site, "portal_catalog", None)
88+
if catalog is None:
89+
return
90+
91+
for name in ("htmltext_lexicon", "plaintext_lexicon", "plone_lexicon"):
92+
if name in catalog.objectIds():
93+
catalog._delObject(name)
94+
log.info("Removed orphaned lexicon: %s", name)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<dtml-var manage_page_header>
2+
<dtml-var manage_tabs>
3+
4+
<main class="container-fluid">
5+
6+
<div class="card mt-3 mb-3">
7+
<div class="card-header">
8+
<strong>Catalog Maintenance</strong>
9+
</div>
10+
<div class="card-body p-3">
11+
<div class="d-flex justify-content-between align-items-center mb-3">
12+
<div>
13+
<strong>Update Catalog</strong>
14+
<p class="text-muted small mb-0">
15+
Re-catalog all currently indexed objects in PostgreSQL.
16+
</p>
17+
</div>
18+
<form action="&dtml-URL1;" class="ml-3">
19+
<button class="btn btn-sm btn-primary" type="submit"
20+
name="manage_catalogReindex:method" value="Update">
21+
Update Catalog
22+
</button>
23+
</form>
24+
</div>
25+
<hr class="my-2" />
26+
<div class="d-flex justify-content-between align-items-center">
27+
<div>
28+
<strong>Clear and Rebuild</strong>
29+
<p class="text-muted small mb-0">
30+
Remove all catalog data from PostgreSQL, then walk the entire portal
31+
and re-index every content object. Use this after schema changes or
32+
data corruption.
33+
</p>
34+
</div>
35+
<form action="&dtml-URL1;" class="ml-3">
36+
<button class="btn btn-sm btn-warning" type="submit"
37+
name="manage_catalogRebuild:method" value="Rebuild">
38+
Clear and Rebuild
39+
</button>
40+
</form>
41+
</div>
42+
</div>
43+
</div>
44+
45+
</main>
46+
47+
<dtml-var manage_page_footer>

0 commit comments

Comments
 (0)