feat: structure and content fingerprints, lit/library-find-similar
Fingerprint::structure()/content() (E11) and lit/library-find-similar (indirect, group Divi-Bibliothek): at most 200 posts per call (cursor), editable entries only (own posts without edit_others_posts), contents primed in chunks of 50, stops at 200 matches or 5000 compared nodes with a note; library entries and Theme Builder layouts are never scanned. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
use stdClass;
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and
|
||||||
|
* lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two
|
||||||
|
* sections built alike with other texts share it. content(): every attribute but the admin label,
|
||||||
|
* builderVersion and globalParent, children included – "the same section". Whitespace between blocks never
|
||||||
|
* counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP.
|
||||||
|
*/
|
||||||
|
final class Fingerprint
|
||||||
|
{
|
||||||
|
/** Top-level attributes that say nothing about what visitors see. */
|
||||||
|
private const IGNORED = ['builderVersion', 'globalParent'];
|
||||||
|
|
||||||
|
public static function structure(Block $block): string
|
||||||
|
{
|
||||||
|
return self::hash(self::shape($block));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function content(Block $block): string
|
||||||
|
{
|
||||||
|
return self::hash(self::body($block));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function shape(Block $block): string
|
||||||
|
{
|
||||||
|
if ($block->isOpaque()) {
|
||||||
|
return 'opaque:' . GlobalInstance::effectiveName($block);
|
||||||
|
}
|
||||||
|
|
||||||
|
$presets = NodeSummary::presetIds($block);
|
||||||
|
sort($presets);
|
||||||
|
$children = array_map(self::shape(...), $block->children());
|
||||||
|
|
||||||
|
return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets)
|
||||||
|
. '[' . implode(';', $children) . ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function body(Block $block): string
|
||||||
|
{
|
||||||
|
if ($block->isOpaque()) {
|
||||||
|
return 'opaque:' . (string) $block->rawOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
$attrs = $block->attrs();
|
||||||
|
foreach (self::IGNORED as $key) {
|
||||||
|
unset($attrs[$key]);
|
||||||
|
}
|
||||||
|
if (is_array($attrs['module']['meta'] ?? null)) {
|
||||||
|
unset($attrs['module']['meta']['adminLabel']);
|
||||||
|
}
|
||||||
|
$children = array_map(self::body(...), $block->children());
|
||||||
|
|
||||||
|
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */
|
||||||
|
private static function sorted(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if ($value instanceof stdClass) {
|
||||||
|
$value = (array) $value;
|
||||||
|
}
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
if (! array_is_list($value)) {
|
||||||
|
ksort($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(self::sorted(...), $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function hash(string $value): string
|
||||||
|
{
|
||||||
|
return substr(hash('sha256', $value), 0, 16);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,7 +168,8 @@ final class DiviIntegration implements Integration
|
|||||||
new DiviSaver(),
|
new DiviSaver(),
|
||||||
$this->log,
|
$this->log,
|
||||||
$this->modules,
|
$this->modules,
|
||||||
$this->diviWritable
|
$this->diviWritable,
|
||||||
|
$this->schema
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<?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\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
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
/** Types with gates of their own (LibraryAccess, ThemeBuilderAccess): never scanned, even if a filter lists them. */
|
||||||
|
private const NOT_SCANNED = [
|
||||||
|
DiviLibraryStore::POST_TYPE,
|
||||||
|
'et_theme_builder',
|
||||||
|
'et_template',
|
||||||
|
'et_header_layout',
|
||||||
|
'et_body_layout',
|
||||||
|
'et_footer_layout',
|
||||||
|
];
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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::NOT_SCANNED, 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];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string>|WP_Error */
|
||||||
|
private function postTypes(mixed $postType): array|WP_Error
|
||||||
|
{
|
||||||
|
if ($postType !== null) {
|
||||||
|
$type = in_array($postType, self::NOT_SCANNED, true)
|
||||||
|
? PostGuard::postType('', $this->schema, 'edit_posts')
|
||||||
|
: 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::NOT_SCANNED),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
|
||||||
|
];
|
||||||
|
if (count($matches) >= self::MAX_MATCHES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,11 +12,13 @@ use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
|||||||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
use LIT\MCP\Integrations\Integration;
|
use LIT\MCP\Integrations\Integration;
|
||||||
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
|
|
||||||
defined('ABSPATH') || exit;
|
defined('ABSPATH') || exit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save and lit-library-insert (direct).
|
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit-library-insert (direct) and, with the
|
||||||
|
* content schema, lit/library-find-similar (E11).
|
||||||
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
* Built by DiviIntegration::library() so it shares the library access (rights, Tools-tab gates –
|
||||||
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
* its id LibraryAccess::GROUP is what the P9 hiding and the write gate ask). Available with the
|
||||||
* Divi 5 builder, like the Divi group.
|
* Divi 5 builder, like the Divi group.
|
||||||
@@ -32,7 +34,8 @@ final class LibraryIntegration implements Integration
|
|||||||
private DiviSaver $saver,
|
private DiviSaver $saver,
|
||||||
private ChangeLog $log,
|
private ChangeLog $log,
|
||||||
private DiviModules $modules,
|
private DiviModules $modules,
|
||||||
private ?Closure $diviWritable = null
|
private ?Closure $diviWritable = null,
|
||||||
|
private ?ContentSchema $schema = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +56,7 @@ final class LibraryIntegration implements Integration
|
|||||||
|
|
||||||
public function abilities(): array
|
public function abilities(): array
|
||||||
{
|
{
|
||||||
return [
|
$abilities = [
|
||||||
new LibraryListAbility(
|
new LibraryListAbility(
|
||||||
$this->access,
|
$this->access,
|
||||||
$this->loader,
|
$this->loader,
|
||||||
@@ -70,5 +73,10 @@ final class LibraryIntegration implements Integration
|
|||||||
),
|
),
|
||||||
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
||||||
];
|
];
|
||||||
|
if ($this->schema !== null) {
|
||||||
|
$abilities[] = new LibraryFindSimilarAbility($this->access, $this->loader, $this->schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $abilities;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||||||
|
|
||||||
|
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\Parser;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class FingerprintTest extends TestCase
|
||||||
|
{
|
||||||
|
/** The section of library entry 1055: section > row > column (24_24) > blurb. */
|
||||||
|
private static function section(): Block
|
||||||
|
{
|
||||||
|
$content = DiviFixtures::markup('stage3/library--library-abschnitt.html');
|
||||||
|
|
||||||
|
return LibraryContent::roots(LibraryContent::load(new Parser(), $content))[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function blurbOf(Block $section): Block
|
||||||
|
{
|
||||||
|
return $section->children()[0]->children()[0]->children()[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdminLabelsAndBuilderVersionDoNotCount(): void
|
||||||
|
{
|
||||||
|
$a = self::section();
|
||||||
|
$b = $a->copy();
|
||||||
|
$attrs = $b->attrs();
|
||||||
|
$attrs['module']['meta']['adminLabel']['desktop']['value'] = 'Anderes Label';
|
||||||
|
$attrs['builderVersion'] = '5.3.0';
|
||||||
|
$b->setAttrs($attrs);
|
||||||
|
|
||||||
|
self::assertSame(16, strlen(Fingerprint::content($a)));
|
||||||
|
self::assertSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||||
|
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOtherTextsKeepTheStructureButNotTheContent(): void
|
||||||
|
{
|
||||||
|
$a = self::section();
|
||||||
|
$b = $a->copy();
|
||||||
|
$blurb = self::blurbOf($b);
|
||||||
|
$attrs = $blurb->attrs();
|
||||||
|
$attrs['title']['innerContent']['desktop']['value'] = 'Anderer Titel';
|
||||||
|
$blurb->setAttrs($attrs);
|
||||||
|
|
||||||
|
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||||
|
self::assertNotSame(Fingerprint::content($a), Fingerprint::content($b));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testColumnLayoutAndModuleTypesChangeTheStructure(): void
|
||||||
|
{
|
||||||
|
$a = self::section();
|
||||||
|
$b = $a->copy();
|
||||||
|
$column = $b->children()[0]->children()[0];
|
||||||
|
$attrs = $column->attrs();
|
||||||
|
$attrs['module']['decoration']['sizing']['desktop']['value']['flexType'] = '12_24';
|
||||||
|
$column->setAttrs($attrs);
|
||||||
|
$c = Block::create('divi/section', [], [Block::create('divi/row', [], [Block::create('divi/column', [], [
|
||||||
|
Block::create('divi/text'),
|
||||||
|
])])]);
|
||||||
|
|
||||||
|
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||||||
|
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,7 @@ final class DiviIntegrationTest extends TestCase
|
|||||||
self::assertSame('Divi-Bibliothek', $library->label());
|
self::assertSame('Divi-Bibliothek', $library->label());
|
||||||
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
||||||
self::assertSame(
|
self::assertSame(
|
||||||
['lit/library-list', 'lit/library-save', 'lit/library-insert'],
|
['lit/library-list', 'lit/library-save', 'lit/library-insert', 'lit/library-find-similar'],
|
||||||
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||||||
);
|
);
|
||||||
foreach ($library->abilities() as $ability) {
|
foreach ($library->abilities() as $ability) {
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Cursor;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Library\LibraryFindSimilarAbility;
|
||||||
|
use LIT\MCP\Tests\Support\DiviFixtures;
|
||||||
|
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class LibraryFindSimilarAbilityTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $variation = '';
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$root = LibraryContent::roots(LibraryContent::load(
|
||||||
|
new Parser(),
|
||||||
|
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||||||
|
))[0];
|
||||||
|
$copy = Serializer::block($root);
|
||||||
|
$this->variation = str_replace('Library-Blurb', 'Anderer Blurb', $copy);
|
||||||
|
$posts = [
|
||||||
|
LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')),
|
||||||
|
new WP_Post([
|
||||||
|
'ID' => 8,
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_title' => 'Über uns',
|
||||||
|
'post_content' => '<!-- wp:divi/placeholder -->' . $this->variation . $copy . '<!-- /wp:divi/placeholder -->',
|
||||||
|
]),
|
||||||
|
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Fremd', 'post_content' => $copy]),
|
||||||
|
];
|
||||||
|
$items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS));
|
||||||
|
LibraryFixtures::given(array_merge($posts, $items));
|
||||||
|
Functions\when('current_user_can')->alias(
|
||||||
|
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9)
|
||||||
|
);
|
||||||
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
||||||
|
'edit_posts' => 'edit_posts',
|
||||||
|
'edit_others_posts' => 'edit_others_posts',
|
||||||
|
]]);
|
||||||
|
Functions\when('_get_non_cached_ids')->returnArg(1);
|
||||||
|
Functions\when('_prime_post_caches')->alias(function (array $ids): void {
|
||||||
|
$this->primed[] = $ids;
|
||||||
|
});
|
||||||
|
Functions\when('wp_cache_delete')->justReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<list<int>> */
|
||||||
|
private array $primed = [];
|
||||||
|
|
||||||
|
private static function ability(array $deniedRoles = []): LibraryFindSimilarAbility
|
||||||
|
{
|
||||||
|
$access = LibraryFixtures::access($deniedRoles);
|
||||||
|
|
||||||
|
return new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFindsIdenticalAndStructurallySimilarNodesOfEditableEntries(): void
|
||||||
|
{
|
||||||
|
$query = [];
|
||||||
|
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
|
||||||
|
$query = $args;
|
||||||
|
|
||||||
|
return [7, 8, 9];
|
||||||
|
});
|
||||||
|
|
||||||
|
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||||
|
|
||||||
|
self::assertSame(1055, $result['reference']['library_id']);
|
||||||
|
self::assertSame('divi/section', $result['reference']['type']);
|
||||||
|
$found = array_map(static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches']);
|
||||||
|
self::assertSame(['7 s1 identical', '8 s0 structure', '8 s1 identical'], $found, 'post 9 is not editable');
|
||||||
|
self::assertSame(Rev::of((string) get_post(8)->post_content), $result['matches'][1]['tree_rev']);
|
||||||
|
self::assertSame(3, $result['scanned']);
|
||||||
|
self::assertNull($result['next_cursor']);
|
||||||
|
self::assertSame(['page'], $query['post_type']);
|
||||||
|
self::assertSame(101, $query['posts_per_page']);
|
||||||
|
self::assertSame('ids', $query['fields']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testANodeOfAPageIsTheReferenceAndIsNotItsOwnMatch(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn([7, 8]);
|
||||||
|
|
||||||
|
$result = self::ability()->execute(['post_id' => 7, 'id' => 's1', 'post_type' => 'page', 'limit' => 1]);
|
||||||
|
|
||||||
|
self::assertSame(['s1'], [$result['reference']['id']]);
|
||||||
|
self::assertSame([], array_filter($result['matches'], static fn (array $m): bool => $m['post_id'] === 7));
|
||||||
|
self::assertSame(1, $result['scanned']);
|
||||||
|
self::assertSame(Cursor::encode(1), $result['next_cursor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusals(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn([]);
|
||||||
|
|
||||||
|
self::assertToolError(self::ability(['divi_library'])->execute(['library_id' => 1055]), 'forbidden');
|
||||||
|
self::assertToolError(self::ability()->execute([]), 'validation_failed');
|
||||||
|
self::assertToolError(self::ability()->execute(['library_id' => 1054]), 'validation_failed', 'a layout entry has several roots');
|
||||||
|
self::assertToolError(self::ability()->execute(['post_id' => 7, 'id' => 'g0']), 'validation_failed');
|
||||||
|
self::assertToolError(self::ability()->execute(['library_id' => 1055, 'cursor' => '%%%']), 'validation_failed');
|
||||||
|
self::assertTrue(self::ability()->annotations()['readonly']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No N+1: the contents come in chunks of CONTENT_CHUNK through one primed post cache each. */
|
||||||
|
public function testLoadsTheContentsInChunks(): void
|
||||||
|
{
|
||||||
|
$ids = range(1000, 1000 + LibraryFindSimilarAbility::CONTENT_CHUNK + 4);
|
||||||
|
Functions\when('get_posts')->justReturn($ids);
|
||||||
|
|
||||||
|
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'limit' => 200]);
|
||||||
|
|
||||||
|
self::assertSame(count($ids), $result['scanned']);
|
||||||
|
self::assertSame([array_slice($ids, 0, LibraryFindSimilarAbility::CONTENT_CHUNK), array_slice(
|
||||||
|
$ids,
|
||||||
|
LibraryFindSimilarAbility::CONTENT_CHUNK
|
||||||
|
)], $this->primed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Without edit_others_posts the query itself keeps to the user's own posts (no cursor oracle). */
|
||||||
|
public function testWithoutEditOthersPostsOnlyOwnPostsAreQueried(): void
|
||||||
|
{
|
||||||
|
$query = [];
|
||||||
|
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
|
||||||
|
$query = $args;
|
||||||
|
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_others_posts');
|
||||||
|
Functions\when('get_current_user_id')->justReturn(42);
|
||||||
|
|
||||||
|
self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||||
|
|
||||||
|
self::assertSame(42, $query['author']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Library entries and Theme Builder layouts are never scanned (they have their own gates). */
|
||||||
|
public function testLibraryAndThemeBuilderTypesAreNotSearchable(): void
|
||||||
|
{
|
||||||
|
Functions\expect('get_posts')->never();
|
||||||
|
|
||||||
|
foreach (['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template'] as $type) {
|
||||||
|
self::assertToolError(
|
||||||
|
self::ability()->execute(['library_id' => 1055, 'post_type' => $type]),
|
||||||
|
'not_found'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** With the library group hidden the library entry is answered like a missing ID (P9/P12). */
|
||||||
|
public function testAHiddenLibraryAnswersLikeAMissingEntry(): void
|
||||||
|
{
|
||||||
|
Functions\when('get_posts')->justReturn([]);
|
||||||
|
$access = new \LIT\MCP\Integrations\Divi\Library\LibraryAccess(
|
||||||
|
LibraryFixtures::store(),
|
||||||
|
LibraryFixtures::roles(),
|
||||||
|
static fn (): bool => true,
|
||||||
|
static fn (): bool => false
|
||||||
|
);
|
||||||
|
$ability = new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema());
|
||||||
|
|
||||||
|
$hidden = $ability->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||||
|
$missing = $ability->execute(['library_id' => 4711, 'post_type' => 'page']);
|
||||||
|
|
||||||
|
self::assertToolError($hidden, 'not_found');
|
||||||
|
self::assertSame(
|
||||||
|
str_replace('1055', 'X', $hidden->get_error_message()),
|
||||||
|
str_replace('4711', 'X', $missing->get_error_message())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** At most MAX_MATCHES matches: the scan stops after that post, says so and resumes behind it. */
|
||||||
|
public function testStopsAtMaxMatchesWithANoteAndACursor(): void
|
||||||
|
{
|
||||||
|
$section = Serializer::block(LibraryContent::roots(LibraryContent::load(
|
||||||
|
new Parser(),
|
||||||
|
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||||||
|
))[0]);
|
||||||
|
$many = static fn (int $id, int $count): WP_Post => new WP_Post([
|
||||||
|
'ID' => $id,
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_title' => 'Viele ' . $id,
|
||||||
|
'post_content' => '<!-- wp:divi/placeholder -->' . str_repeat($section, $count)
|
||||||
|
. '<!-- /wp:divi/placeholder -->',
|
||||||
|
]);
|
||||||
|
$items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS));
|
||||||
|
LibraryFixtures::given(array_merge([$many(20, 150), $many(21, 120), $many(22, 5)], $items));
|
||||||
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) []]);
|
||||||
|
Functions\when('get_posts')->justReturn([20, 21, 22]);
|
||||||
|
|
||||||
|
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||||
|
|
||||||
|
self::assertCount(LibraryFindSimilarAbility::MAX_MATCHES, $result['matches']);
|
||||||
|
self::assertSame(2, $result['scanned']);
|
||||||
|
self::assertSame(Cursor::encode(2), $result['next_cursor']);
|
||||||
|
self::assertStringContainsString('200', $result['note']);
|
||||||
|
|
||||||
|
Functions\when('get_posts')->justReturn([22]);
|
||||||
|
$rest = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page', 'cursor' => Cursor::encode(2)]);
|
||||||
|
self::assertCount(5, $rest['matches']);
|
||||||
|
self::assertArrayNotHasKey('note', $rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user