fix: find-similar marks replaceable matches and names excluded types (review M-3, M-7, M-8)
Every match carries replaceable: true only when it is identical and in a page or post, the scope lit/library-make-global replaces; the description and the guide say so. An excluded post type is answered like any unavailable one, by its name (PostGuard::unavailableType()), and the excluded list takes the Theme Builder types from DiviThemeBuilder::postTypes(). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -41,7 +41,8 @@ Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederv
|
|||||||
Durchsucht werden Einträge der Divi-Inhaltstypen (optional nur `post_type`), nie Bibliothekseinträge oder
|
Durchsucht werden Einträge der Divi-Inhaltstypen (optional nur `post_type`), nie Bibliothekseinträge oder
|
||||||
Theme-Builder-Layouts; nur Einträge, die das Konto bearbeiten darf. Höchstens 200 Einträge je Aufruf
|
Theme-Builder-Layouts; nur Einträge, die das Konto bearbeiten darf. Höchstens 200 Einträge je Aufruf
|
||||||
(`limit`), weiter mit `next_cursor`; bei sehr vielen Treffern endet ein Aufruf früher und sagt das in `note`.
|
(`limit`), weiter mit `next_cursor`; bei sehr vielen Treffern endet ein Aufruf früher und sagt das in `note`.
|
||||||
Jeder Treffer nennt `tree_rev`.
|
Jeder Treffer nennt `tree_rev` und `replaceable` (`true`: inhaltsgleich und in einer Seite oder einem Beitrag –
|
||||||
|
nur solche Treffer ersetzt `lit/library-make-global`).
|
||||||
- `lit/library-make-global`: einen Knoten einer Seite oder eines Beitrags in ein globales Modul umwandeln
|
- `lit/library-make-global`: einen Knoten einer Seite oder eines Beitrags in ein globales Modul umwandeln
|
||||||
(`post_id`, `tree_rev`, `id`, `title`, optional `categories`/`tags`): neuer globaler Eintrag mit genau diesem
|
(`post_id`, `tree_rev`, `id`, `title`, optional `categories`/`tags`): neuer globaler Eintrag mit genau diesem
|
||||||
Inhalt, der Knoten wird durch die Instanz ersetzt – alles oder nichts. Weitere, inhaltsgleiche Vorkommen
|
Inhalt, der Knoten wird durch die Instanz ersetzt – alles oder nichts. Weitere, inhaltsgleiche Vorkommen
|
||||||
@@ -139,6 +140,7 @@ Fügt eine Divi-Änderung einen Abschnitt ein, der wie ein Abschnitt der Bibliot
|
|||||||
Treffer mit `match: "structure"` unterscheiden sich im Inhalt – vorher klären, welche Fassung gilt, und die
|
Treffer mit `match: "structure"` unterscheiden sich im Inhalt – vorher klären, welche Fassung gilt, und die
|
||||||
übrigen angleichen (`lit-divi-update`) oder weglassen.
|
übrigen angleichen (`lit-divi-update`) oder weglassen.
|
||||||
3. Nach Zustimmung `lit/library-make-global` mit dem gewählten Abschnitt.
|
3. Nach Zustimmung `lit/library-make-global` mit dem gewählten Abschnitt.
|
||||||
4. Die inhaltsgleichen Vorkommen mit `lit/library-make-global` (`library_id`, `targets` mit der `tree_rev` aus
|
4. Die Treffer mit `replaceable: true` mit `lit/library-make-global` (`library_id`, `targets` mit der `tree_rev`
|
||||||
`lit/library-find-similar`) ersetzen: Probelauf zeigen, dann `confirm: true`.
|
aus `lit/library-find-similar`) ersetzen: Probelauf zeigen, dann `confirm: true`. Inhaltsgleiche Treffer in
|
||||||
|
anderen Inhaltstypen (`replaceable: false`) im Divi-Builder durch das globale Modul ersetzen.
|
||||||
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
|
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
|||||||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||||||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||||||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
@@ -48,15 +49,6 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
/** Post contents read with one primed cache (bounded memory). */
|
/** Post contents read with one primed cache (bounded memory). */
|
||||||
public const CONTENT_CHUNK = 50;
|
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'];
|
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
|
||||||
|
|
||||||
@@ -84,8 +76,8 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
|
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
|
||||||
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
|
. '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 '
|
. '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 '
|
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen in Seiten und Beiträgen (replaceable: true) '
|
||||||
. '(library_id, targets).';
|
. 'ersetzt lit/library-make-global (library_id, targets).';
|
||||||
}
|
}
|
||||||
|
|
||||||
public function inputSchema(): array
|
public function inputSchema(): array
|
||||||
@@ -192,7 +184,7 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
}
|
}
|
||||||
++$scanned;
|
++$scanned;
|
||||||
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
|
$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)) {
|
if ($post instanceof WP_Post && ! in_array((string) $post->post_type, self::notScanned(), true)) {
|
||||||
$this->collect($post, $reference, $matches, $nodes);
|
$this->collect($post, $reference, $matches, $nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,19 +294,31 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
|
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 */
|
/** @return list<string>|WP_Error */
|
||||||
private function postTypes(mixed $postType): array|WP_Error
|
private function postTypes(mixed $postType): array|WP_Error
|
||||||
{
|
{
|
||||||
if ($postType !== null) {
|
if ($postType !== null) {
|
||||||
$type = in_array($postType, self::NOT_SCANNED, true)
|
// Review M-7: an excluded type answers like any unavailable one, by its name.
|
||||||
? PostGuard::postType('', $this->schema, 'edit_posts')
|
$type = in_array($postType, self::notScanned(), true)
|
||||||
|
? PostGuard::unavailableType((string) $postType)
|
||||||
: PostGuard::postType($postType, $this->schema, 'edit_posts');
|
: PostGuard::postType($postType, $this->schema, 'edit_posts');
|
||||||
|
|
||||||
return $type instanceof WP_Error ? $type : [(string) $postType];
|
return $type instanceof WP_Error ? $type : [(string) $postType];
|
||||||
}
|
}
|
||||||
|
|
||||||
$types = array_values(array_filter(
|
$types = array_values(array_filter(
|
||||||
array_diff($this->schema->postTypeNames(), self::NOT_SCANNED),
|
array_diff($this->schema->postTypeNames(), self::notScanned()),
|
||||||
fn (string $type): bool => $this->schema->diviEnabled($type)
|
fn (string $type): bool => $this->schema->diviEnabled($type)
|
||||||
));
|
));
|
||||||
|
|
||||||
@@ -355,6 +359,7 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$identical = Fingerprint::content($block) === $reference['info']['content'];
|
||||||
$matches[] = [
|
$matches[] = [
|
||||||
'post_id' => (int) $post->ID,
|
'post_id' => (int) $post->ID,
|
||||||
'title' => (string) $post->post_title,
|
'title' => (string) $post->post_title,
|
||||||
@@ -363,7 +368,10 @@ final class LibraryFindSimilarAbility implements Ability
|
|||||||
'id' => $entry['id'],
|
'id' => $entry['id'],
|
||||||
'label' => NodeSummary::label($block),
|
'label' => NodeSummary::label($block),
|
||||||
'tree_rev' => Rev::of($content),
|
'tree_rev' => Rev::of($content),
|
||||||
'match' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
|
'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) {
|
if (count($matches) >= self::MAX_MATCHES) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -136,15 +136,21 @@ final class PostGuard
|
|||||||
* so a plain `object` return type is accurate and still lets callers branch with
|
* so a plain `object` return type is accurate and still lets callers branch with
|
||||||
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
|
* `is_wp_error()` / `instanceof WP_Error`, exactly like editable() above.
|
||||||
*/
|
*/
|
||||||
|
/** The answer for a post type that is not available to the tools – the same for unknown and excluded ones. */
|
||||||
|
public static function unavailableType(string $name): WP_Error
|
||||||
|
{
|
||||||
|
return Errors::notFound(
|
||||||
|
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
|
||||||
|
'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object
|
public static function postType(mixed $postType, ContentSchema $schema, string $cap): object
|
||||||
{
|
{
|
||||||
$name = is_string($postType) ? $postType : '';
|
$name = is_string($postType) ? $postType : '';
|
||||||
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
|
$object = $name !== '' && $schema->isManaged($name) ? get_post_type_object($name) : null;
|
||||||
if (! is_object($object)) {
|
if (! is_object($object)) {
|
||||||
return Errors::notFound(
|
return self::unavailableType($name);
|
||||||
sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $name),
|
|
||||||
'Verfügbare Inhaltstypen über lit/content-types (direkt: lit-content-types) abrufen.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
|
$capability = isset($object->cap->{$cap}) ? (string) $object->cap->{$cap} : $cap;
|
||||||
|
|||||||
@@ -150,14 +150,50 @@ final class LibraryFindSimilarAbilityTest extends TestCase
|
|||||||
{
|
{
|
||||||
Functions\expect('get_posts')->never();
|
Functions\expect('get_posts')->never();
|
||||||
|
|
||||||
foreach (['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template'] as $type) {
|
foreach (
|
||||||
self::assertToolError(
|
['et_pb_layout', 'et_body_layout', 'et_header_layout', 'et_footer_layout', 'et_template', 'et_theme_builder']
|
||||||
self::ability()->execute(['library_id' => 1055, 'post_type' => $type]),
|
as $type
|
||||||
'not_found'
|
) {
|
||||||
);
|
$refused = self::ability()->execute(['library_id' => 1055, 'post_type' => $type]);
|
||||||
|
self::assertToolError($refused, 'not_found');
|
||||||
|
// Review M-7: named like any unavailable type, never as "".
|
||||||
|
self::assertSame(sprintf('Inhaltstyp "%s" ist nicht verfügbar.', $type), self::clientError($refused)['message']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review M-8: lit/library-make-global replaces only pages and posts – every match says whether it can be
|
||||||
|
* replaced there (identical and of such a type).
|
||||||
|
*/
|
||||||
|
public function testMatchesSayWhetherMakeGlobalCanReplaceThem(): void
|
||||||
|
{
|
||||||
|
$project = get_post(8);
|
||||||
|
$project = new WP_Post(['ID' => 10, 'post_type' => 'project', 'post_status' => 'publish', 'post_title' => 'Projekt',
|
||||||
|
'post_content' => $project->post_content]);
|
||||||
|
LibraryFixtures::given(array_merge([get_post(8), $project], array_map(
|
||||||
|
static fn (int $id): WP_Post => LibraryFixtures::item($id),
|
||||||
|
array_keys(LibraryFixtures::ITEMS)
|
||||||
|
)));
|
||||||
|
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
||||||
|
'edit_posts' => 'edit_posts',
|
||||||
|
'edit_others_posts' => 'edit_others_posts',
|
||||||
|
]]);
|
||||||
|
// The query is mocked: it stands for a scan over several Divi-enabled types.
|
||||||
|
Functions\when('get_posts')->justReturn([8, 10]);
|
||||||
|
|
||||||
|
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||||||
|
|
||||||
|
$found = array_map(
|
||||||
|
static fn (array $m): string => sprintf('%d %s %s %s', $m['post_id'], $m['id'], $m['match'], json_encode($m['replaceable'])),
|
||||||
|
$result['matches']
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
['8 s0 structure false', '8 s1 identical true', '10 s0 structure false', '10 s1 identical false'],
|
||||||
|
$found
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('replaceable', self::ability()->description());
|
||||||
|
}
|
||||||
|
|
||||||
/** With the library group hidden the library entry is answered like a missing ID (P9/P12). */
|
/** With the library group hidden the library entry is answered like a missing ID (P9/P12). */
|
||||||
public function testAHiddenLibraryAnswersLikeAMissingEntry(): void
|
public function testAHiddenLibraryAnswersLikeAMissingEntry(): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user