refactor: GlobalUsages sucht über UsageQuery – Bibliothekssuche als LibraryUsageQuery

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-16 17:36:21 +02:00
co-authored by Claude Sonnet 5.5
parent 3fe83e39d3
commit 454116851e
5 changed files with 171 additions and 43 deletions
+50 -43
View File
@@ -11,11 +11,11 @@ use WP_Post;
defined('ABSPATH') || exit;
/**
* Posts (pages, Theme Builder layouts, other library entries) that show library entry $id: every
* living post whose content references it in any form Divi renders (rows(), ruling P23 – Divi 5
* instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed). Candidates come
* from one query (ID, status and type), their content is parsed in bounded chunks (audit L1); the
* rights of the caller are then checked in chunks with a bounded post cache:
* Posts (pages, Theme Builder layouts, other library entries) that reference something a UsageQuery describes –
* a library entry $id (LibraryUsageQuery: every living post whose content references it in any form Divi renders,
* ruling P23 – Divi 5 instances in any JSON spelling, Divi 4 global_module shortcodes; fail closed) or, since
* Etappe 4, a preset. Candidates come from one query (ID, status and type), their content is parsed in bounded
* chunks (audit L1); the rights of the caller are then checked in chunks with a bounded post cache:
* - readable posts are listed (at most LIMIT, with title); unreadable ones only count (audit L4);
* - live posts (ruling P8a) the caller may not edit_post block a confirmed edit – by ID when
* readable, else as a count; above HARD_CAP live posts the edit is refused as a whole;
@@ -76,7 +76,48 @@ class GlobalUsages
*/
public function report(int $libraryId, bool $withBlocked = true): UsageReport
{
$rows = $this->rows($libraryId);
return $this->reportOfRows($this->rows($libraryId), $withBlocked);
}
/** report() for any reference – a library entry, a preset (Etappe 4) – described by $query. */
public function reportFor(UsageQuery $query, bool $withBlocked = true): UsageReport
{
return $this->reportOfRows($this->rowsFor($query), $withBlocked);
}
/** @return list<array{id: int, status: string, type: string}> */
public function rows(int $libraryId): array
{
return $this->rowsFor(new LibraryUsageQuery($libraryId));
}
/**
* Every post (no revision, not trashed) whose content $query matches (fail closed). Candidates come from one
* query (ID, status and type only); their content is read in chunks of CONTENT_CHUNK; beyond PARSE_CAP
* candidates the rest counts as references unread (audit L1).
*
* @return list<array{id: int, status: string, type: string}>
*/
public function rowsFor(UsageQuery $query): array
{
$candidates = $this->candidates($query);
$result = [];
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
foreach ($chunk as $row) {
// A candidate whose content could not be read counts too (fail closed).
if (! isset($contents[$row['id']]) || $query->matches($contents[$row['id']])) {
$result[] = $row;
}
}
}
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
}
/** @param list<array{id: int, status: string, type: string}> $rows */
private function reportOfRows(array $rows, bool $withBlocked): UsageReport
{
$live = count(array_filter($rows, static fn (array $row): bool => in_array($row['status'], self::LIVE, true)));
$tooMany = $withBlocked && $live > self::HARD_CAP;
$checkEdit = $withBlocked && ! $tooMany;
@@ -139,48 +180,14 @@ class GlobalUsages
return new UsageReport(count($rows), $items, $hidden, $live, $blocked, $blockedHidden, $tooMany);
}
/**
* Every post (no revision, not trashed) that shows $libraryId (ruling P23, fail closed). The
* candidates come from one query (ID, status and type only): content with "globalModule", a
* Divi 4 "global_module" (both case-insensitive) or a JSON escape of a letter (an escaped key such as globalModule
* always has one). Their content is then read in chunks of CONTENT_CHUNK and parsed by
* UsageMatcher; beyond PARSE_CAP candidates the rest counts as references unread (audit L1).
*
* @return list<array{id: int, status: string, type: string}>
*/
public function rows(int $libraryId): array
{
$candidates = $this->candidates();
$result = [];
foreach (array_chunk(array_slice($candidates, 0, self::PARSE_CAP), self::CONTENT_CHUNK) as $chunk) {
$contents = $this->contents(array_map(static fn (array $row): int => $row['id'], $chunk));
foreach ($chunk as $row) {
// A candidate whose content could not be read counts too (fail closed).
if (! isset($contents[$row['id']]) || UsageMatcher::shows($contents[$row['id']], $libraryId)) {
$result[] = $row;
}
}
}
return array_merge($result, array_slice($candidates, self::PARSE_CAP));
}
/** @return list<array{id: int, status: string, type: string}> */
private function candidates(): array
private function candidates(UsageQuery $query): array
{
$db = $this->db();
$escapedLetter = str_repeat(chr(92), 2) . 'u00[4-7][0-9a-fA-F]';
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
$rows = $db->get_results(
$db->prepare(
// Ruling P24: LOWER() keeps the LIKEs case-insensitive under any column collation.
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE (LOWER(post_content) LIKE %s OR '
. "LOWER(post_content) LIKE %s OR post_content REGEXP %s) AND post_type <> 'revision' AND "
. self::LISTED . ' ORDER BY ID ASC',
'%' . $db->esc_like('globalmodule') . '%',
'%' . $db->esc_like('global_module') . '%',
$escapedLetter
),
'SELECT ID, post_status, post_type FROM ' . $db->posts . ' WHERE ' . $query->candidateCondition($db)
. " AND post_type <> 'revision' AND " . self::LISTED . ' ORDER BY ID ASC',
'ARRAY_A'
);
// phpcs:enable