Files
WP-LIT-MCP/src/Integrations/Divi/Library/LibraryFindSimilarAbility.php
T

393 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Library;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Cursor;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* lit/library-find-similar (spec §5.7, E11): nodes across the site built like a reference node (post_id + id) or a
* library entry (library_id) – "identical" (Fingerprint::content) or "structure" (Fingerprint::structure, other
* values). Scans at most MAX_POSTS entries per call in ID order (cursor), only of Divi-enabled managed types – never
* library entries or Theme Builder layouts, which have their own gates –, only entries the user may edit (query
* restricted to own posts without edit_others_posts, like content-query), no index or meta (F31). Contents come in
* chunks of CONTENT_CHUNK through one primed post cache each (no query per post, bounded memory, as GlobalUsages).
* A call stops after the post that reaches MAX_MATCHES matches or MAX_NODES fingerprinted nodes; `note` says so
* and next_cursor resumes behind it. Needs the library right (canList); a hidden library answers its entries like
* missing IDs (LibraryAccess::item, P9/P12). Only hashes and the matched nodes' labels are returned.
*/
final class LibraryFindSimilarAbility implements Ability, RequiresDiviFeatures
{
public const MAX_POSTS = 200;
public const DEFAULT_POSTS = 100;
public const MAX_MATCHES = 200;
/** At most this many nodes of the reference's type are fingerprinted per call. */
public const MAX_NODES = 5000;
/** Post contents read with one primed cache (bounded memory). */
public const CONTENT_CHUNK = 50;
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
public function __construct(
private LibraryAccess $access,
private DiviPageLoader $loader,
private ContentSchema $schema,
private Parser $parser = new Parser()
) {
}
public function name(): string
{
return 'lit/library-find-similar';
}
public function label(): string
{
return 'Ähnliche Abschnitte finden';
}
public function description(): string
{
return 'Sucht seitenübergreifend Knoten, die wie ein gegebener Knoten (post_id und id aus '
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) '
. 'ersetzt lit/library-make-global (library_id, targets).';
}
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'post_id' => ['type' => 'integer', 'minimum' => 1],
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'],
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'],
'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'],
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS],
'cursor' => ['type' => 'string'],
],
'additionalProperties' => false,
];
}
public function outputSchema(): ?array
{
return [
'type' => 'object',
'properties' => [
'reference' => ['type' => 'object'],
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
'scanned' => ['type' => 'integer'],
'next_cursor' => ['type' => ['string', 'null']],
'note' => ['type' => 'string'],
],
'required' => ['reference', 'matches', 'scanned', 'next_cursor'],
];
}
public function annotations(): array
{
return Annotations::read();
}
public function isDirect(): bool
{
return false;
}
/**
* Scans Divi pages (PageRead) of the types that use the builder (Catalog), after the library right (Roles). A
* library entry as reference is read through LibraryAccess (Roles), no Divi call beyond that.
*/
public function diviFeatures(array $input): array
{
return [DiviFeature::PageRead, DiviFeature::Catalog, DiviFeature::Roles];
}
public function permission(array $input): bool|WP_Error
{
return $this->access->canList() ?? true;
}
public function execute(array $input): mixed
{
$refusal = $this->access->canList();
if ($refusal !== null) {
return $refusal;
}
$reference = $this->reference($input);
if ($reference instanceof WP_Error) {
return $reference;
}
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
if ($offset === null) {
return Cursor::invalid();
}
$types = $this->postTypes($input['post_type'] ?? null);
if ($types instanceof WP_Error) {
return $types;
}
$limit = is_int($input['limit'] ?? null)
? max(1, min(self::MAX_POSTS, $input['limit']))
: self::DEFAULT_POSTS;
$args = [
'post_type' => $types,
'post_status' => self::LIVING,
'posts_per_page' => $limit + 1,
'offset' => $offset,
'orderby' => 'ID',
'order' => 'ASC',
'fields' => 'ids',
'no_found_rows' => true,
'suppress_filters' => false,
];
foreach ($types as $type) {
$own = PostGuard::ownPostsOnly($type);
if ($own !== null) {
$args['author'] = $own;
break;
}
}
$ids = array_values(array_map('intval', (array) get_posts($args)));
$scan = array_slice($ids, 0, $limit);
$matches = [];
$nodes = 0;
$scanned = 0;
foreach (array_chunk($scan, self::CONTENT_CHUNK) as $chunk) {
$fresh = _get_non_cached_ids($chunk, 'posts');
_prime_post_caches($chunk, false, false);
foreach ($chunk as $id) {
if (self::full($matches, $nodes)) {
break;
}
++$scanned;
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) {
$this->collect($post, $reference, $matches, $nodes);
}
}
foreach ($fresh as $id) {
wp_cache_delete((int) $id, 'posts');
}
if (self::full($matches, $nodes)) {
break;
}
}
$stopped = $scanned < count($scan);
$result = [
'reference' => $reference['info'],
'matches' => $matches,
'scanned' => $scanned,
'next_cursor' => $stopped || count($ids) > $limit ? Cursor::encode($offset + $scanned) : null,
];
if (self::full($matches, $nodes)) {
$result['note'] = sprintf(
'Suche nach %d Einträgen angehalten (höchstens %d Treffer bzw. %d verglichene Knoten je Aufruf); '
. 'der letzte Eintrag kann weitere Treffer enthalten. Weiter mit next_cursor, oder mit post_type '
. 'eingrenzen.',
$scanned,
self::MAX_MATCHES,
self::MAX_NODES
);
}
return $result;
}
/** @param list<array<string, mixed>> $matches */
private static function full(array $matches, int $nodes): bool
{
return count($matches) >= self::MAX_MATCHES || $nodes >= self::MAX_NODES;
}
/**
* @param array<string, mixed> $input
* @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string}|WP_Error
*/
private function reference(array $input): array|WP_Error
{
if (isset($input['library_id'])) {
$item = $this->access->item($input['library_id'], false);
if ($item === null) {
return PostGuard::notFound((int) PostGuard::id($input['library_id']));
}
if ($item instanceof WP_Error) {
return $item;
}
try {
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
} catch (MalformedMarkup) {
$roots = [];
}
if (count($roots) !== 1 || $roots[0]->isOpaque()) {
return Errors::invalidInput(
sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID),
['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul']
);
}
return self::describe($roots[0], null, null, (int) $item->ID);
}
if (! isset($input['post_id'])) {
return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [
'library_id' => 'oder post_id und id',
]);
}
$page = $this->loader->load($input['post_id'], false);
if ($page instanceof WP_Error) {
return $page;
}
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
$node = $page->index->find($id);
if ($node === null) {
return DiviErrors::unknownNode($id, $page->post);
}
if ($node->isOpaque()) {
return Errors::invalidInput(
'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.',
['id' => 'lesbarer Knoten']
);
}
return self::describe($node, (int) $page->post->ID, $id, null);
}
/** @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} */
private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array
{
$info = array_filter([
'type' => $block->name(),
'structure' => Fingerprint::structure($block),
'content' => Fingerprint::content($block),
'post_id' => $postId,
'id' => $id,
'library_id' => $libraryId,
], static fn (mixed $value): bool => $value !== null);
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
}
/**
* Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them
* (review M-3: the Theme Builder types from DiviThemeBuilder::postTypes()).
*
* @return list<string>
*/
private static function notScanned(): array
{
return array_merge([DiviLibraryStore::POST_TYPE], DiviThemeBuilder::postTypes());
}
/** @return list<string>|WP_Error */
private function postTypes(mixed $postType): array|WP_Error
{
if ($postType !== null) {
// Review M-7: an excluded type answers like any unavailable one, by its name.
$type = in_array($postType, self::notScanned(), true)
? PostGuard::unavailableType((string) $postType)
: PostGuard::postType($postType, $this->schema, 'edit_posts');
return $type instanceof WP_Error ? $type : [(string) $postType];
}
$types = array_values(array_filter(
array_diff($this->schema->postTypeNames(), self::notScanned()),
fn (string $type): bool => $this->schema->diviEnabled($type)
));
return $types !== [] ? $types : Errors::notFound(
'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.',
'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.'
);
}
/**
* @param array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} $reference
* @param list<array<string, mixed>> $matches
*/
private function collect(WP_Post $post, array $reference, array &$matches, int &$nodes): void
{
$content = (string) $post->post_content;
if (! str_contains($content, '<!-- wp:divi/')) {
return;
}
try {
$index = NodeIndex::build($this->parser->parse($content));
} catch (MalformedMarkup) {
return;
}
$name = $reference['block']->name();
foreach ($index->entries() as $entry) {
$block = $entry['block'];
$self = (int) $post->ID === $reference['post_id'] && $entry['id'] === $reference['id'];
if ($self || $block->isOpaque() || $block->name() !== $name) {
continue;
}
if (++$nodes > self::MAX_NODES) {
return;
}
if (Fingerprint::structure($block) !== $reference['info']['structure']) {
continue;
}
$identical = Fingerprint::content($block) === $reference['info']['content'];
$matches[] = [
'post_id' => (int) $post->ID,
'title' => (string) $post->post_title,
'status' => (string) $post->post_status,
'type' => (string) $post->post_type,
'id' => $entry['id'],
'label' => NodeSummary::label($block),
'tree_rev' => Rev::of($content),
'match' => $identical ? 'identical' : 'structure',
// Review M-8: lit/library-make-global replaces identical occurrences in pages and posts only.
'replaceable' => $identical
&& in_array((string) $post->post_type, LibraryMakeGlobalAbility::POST_TYPES, true),
];
if (count($matches) >= self::MAX_MATCHES) {
return;
}
}
}
}