diff --git a/docs/guides/theme-builder.md b/docs/guides/theme-builder.md index 4ac7eb0..4e86c6e 100644 --- a/docs/guides/theme-builder.md +++ b/docs/guides/theme-builder.md @@ -76,7 +76,8 @@ bleiben die Felder ungeprüft (die Antwort sagt das). Kopf- und Fußzeile werden - Speichert jemand in einem Theme-Builder-Fenster, das vor dem Anlegen einer Vorlage geöffnet wurde, entfernt Divi die neue Vorlage wieder aus der Liste und löscht sie 7 Tage später. `lit/tb-list` zeigt sie dann unter `detached` (mit `marked_unused`). Nur auf ausdrücklichen Wunsch: `lit/tb-assign` mit `reattach: true` ordnet - sie wieder zu – immer deaktiviert, und nur, wenn ihre Layouts noch aktiv sind. + sie wieder zu – immer deaktiviert, nur Vorlagen unter `detached` (keine, die eine Theme-Builder-Vorlage der + Divi-Bibliothek nutzt) und nur, wenn ihre Layouts noch aktiv sind. ## Rechte diff --git a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php index 0457fa2..aa9eede 100644 --- a/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php +++ b/src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php @@ -177,6 +177,23 @@ class DiviThemeBuilder return array_values(array_map('intval', is_array($ids) ? $ids : [])); } + /** + * Whether any Theme Builder post (live, draft or a Theme Builder library item) lists template $templateId – + * the criterion detachedIds() uses; lit/tb-assign reattaches only templates none lists (audit L4). + */ + public function isListedAnywhere(int $templateId): bool + { + global $wpdb; + // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- the _et_template rows of every Theme Builder post. + $found = $wpdb->get_var($wpdb->prepare( + "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key = %s AND meta_value = %s LIMIT 1", + self::TEMPLATE_LIST, + (string) $templateId + )); + + return $found !== null; + } + /** @throws RuntimeException */ public function insertLayout(string $area, string $title): int { diff --git a/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php index d595d34..b4f57bf 100644 --- a/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php +++ b/src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php @@ -23,7 +23,8 @@ defined('ABSPATH') || exit; * (ruling Q3/B3). A live template changes the website at once, so it is a dry run first and needs confirm: true * (ruling Q9/B22), listing both lists before/after and overlaps (identical and possible, B9) with other live * templates for the resulting lists. reattach: true puts a template a stale Theme Builder save removed back on the - * live list (only on the user's request, B19) – always disabled, Divi's "unused" marks removed from it and its + * live list (only on the user's request, B19; only one no Theme Builder post lists – tb-list's detached, audit L4) + * – always disabled, Divi's "unused" marks removed from it and its * layouts, which must still be published (B10). Templates have no revisions: the previous conditions are in the * answer and the change log; a failed write restores them (and undoes the reattach). */ @@ -144,13 +145,30 @@ final class TbAssignAbility implements Ability return $this->write($template, $useOn['ids'], $excludeFrom['ids'], $reattach); } - /** A detached template needs reattach: true, a live Theme Builder post and published layouts (B10). */ + /** + * A detached template needs reattach: true, a live Theme Builder post and published layouts (B10). Detached + * means what tb-list shows as detached: no Theme Builder post lists it – a template of a Theme Builder library + * item or draft is none (audit L4). + */ private function reattachRefusal(Template $template, bool $reattach): ?WP_Error { if ($template->attached) { return null; } + if ($this->access->store()->isListedAnywhere($template->id)) { + return Errors::invalidInput( + sprintf( + 'Vorlage %d ist keine entfernte Vorlage (siehe detached in lit/tb-list); nichts wurde gespeichert.', + $template->id + ), + [ + 'template_id' => 'nur Vorlagen der Website (templates) oder entfernte Vorlagen (detached) aus ' + . 'lit/tb-list', + ] + ); + } + if (! $reattach) { $marked = $template->markedUnused === null ? '' diff --git a/tests/Support/ThemeBuilderFixtures.php b/tests/Support/ThemeBuilderFixtures.php index 774c65b..1bd9e72 100644 --- a/tests/Support/ThemeBuilderFixtures.php +++ b/tests/Support/ThemeBuilderFixtures.php @@ -128,6 +128,9 @@ final class ThemeBuilderFixtures $store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates)); $store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null)->byDefault(); $store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached)); + $store->allows('isListedAnywhere')->andReturnUsing( + static fn (int $id): bool => in_array($id, array_map(static fn (Template $t): int => $t->id, $templates), true) + )->byDefault(); $store->allows('flatConditions')->andReturn(self::conditions()); return $store; diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php index 9be6ea8..6c2048e 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php @@ -26,6 +26,38 @@ final class DiviThemeBuilderTest extends TestCase self::assertNull(DiviThemeBuilder::areaOf('page')); } + /** Review M-3: one list of every Theme Builder post type. */ + public function testPostTypesNameEveryThemeBuilderType(): void + { + self::assertSame( + ['et_theme_builder', 'et_template', 'et_header_layout', 'et_body_layout', 'et_footer_layout'], + DiviThemeBuilder::postTypes() + ); + } + + /** Audit L4: a template is listed when any Theme Builder post's _et_template row names it. */ + public function testIsListedAnywhereAsksEveryThemeBuilderPost(): void + { + $db = Mockery::mock(\wpdb::class)->makePartial(); + $db->postmeta = 'wp_postmeta'; + $sql = []; + $db->allows('get_var')->andReturnUsing(static function (string $query) use (&$sql): ?string { + $sql[] = $query; + + return str_contains($query, "'1066'") ? '1' : null; + }); + $GLOBALS['wpdb'] = $db; + + try { + self::assertTrue((new DiviThemeBuilder())->isListedAnywhere(1066)); + self::assertFalse((new DiviThemeBuilder())->isListedAnywhere(2394)); + } finally { + unset($GLOBALS['wpdb']); + } + self::assertStringContainsString("meta_key = '_et_template'", $sql[0]); + self::assertStringContainsString('LIMIT 1', $sql[0]); + } + /** F17/F18: Divi wp_die()s without edit_others_posts – the adapter never gets there. */ public function testWithoutEditOthersPostsDiviIsNeverCalled(): void { diff --git a/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php b/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php index 9cdab43..e77e23b 100644 --- a/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php +++ b/tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php @@ -180,6 +180,27 @@ final class TbAssignAbilityTest extends TestCase self::assertTrue($result['previous']['enabled']); } + /** + * Audit L4: reattach takes only templates no Theme Builder post lists (tb-list's detached) – not one that a + * Theme Builder library item or draft lists; nothing is written. + */ + public function testReattachRefusesATemplateAnotherThemeBuilderPostLists(): void + { + $listedElsewhere = ThemeBuilderFixtures::template(1066, ['attached' => false]); + $store = ThemeBuilderFixtures::store([], 0, [], [$listedElsewhere]); + $store->allows('isListedAnywhere')->with(1066)->andReturn(true); + foreach (['setEnabled', 'attach', 'unmarkUnused', 'setConditions'] as $method) { + $store->shouldReceive($method)->never(); + } + + foreach ([['reattach' => true], []] as $extra) { + $refused = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($listedElsewhere, $extra)); + self::assertToolError($refused, 'validation_failed'); + self::assertStringContainsString('keine entfernte Vorlage', self::clientError($refused)['message']); + self::assertArrayHasKey('template_id', self::errorFields($refused)); + } + } + /** Ruling Q8/B10: a layout Divi trashed meanwhile blocks the reattach; nothing is written. */ public function testReattachNeedsPublishedLayouts(): void {