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:
2026-09-27 21:13:33 +02:00
co-authored by Claude Opus 5.5
parent 6be2e65432
commit f82964f617
7 changed files with 758 additions and 5 deletions
@@ -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);
}
}