Files
WP-LIT-MCP/src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php
T
janniclas.loosenandClaude Opus 5.5 14bc954ecc fix: Theme Builder minors of the final review (M-4, M-5, M-6, M-9, M-10, M-11, M-12 c)
- tb-list applies the detached limit after the edit filter (reads up to 500
  IDs) and notes when more are not listed (M-4); an invalid template_id is
  invalid input, shared with ThemeBuilderAccess::template() (M-5).
- Templates carry their status; tb-list notes that a live template outside
  publish (e.g. trashed) can only be switched off (M-10).
- changeableTemplate() refuses without canUse() first, like tb-list and
  tb-create-template; the layout/template refusal difference is documented
  (M-9).
- tb-create-template reads the default template from editableLiveTemplates()
  (M-11); the condition-ID character set (role slugs) is documented (M-6);
  tb-assign documents that a failed reattach keeps Divi's unused marks
  removed (M-12 c).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
2026-09-27 23:12:14 +02:00

383 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\ThemeBuilder;
use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;
defined('ABSPATH') || exit;
/**
* Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs
* edit_others_posts (Divi's own TB requirement, F19/F23), edit_theme_options (the gate of Divi's Theme
* Builder page, theme-builder.php:2059-2063, ruling Q15) and the Divi role option theme_builder (off for
* every role but administrator by default, F12); one layout or template additionally edit_post on it.
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
* (P12 pattern); the Theme Builder tools themselves (tb-list, tb-create-template, tb-assign, tb-activate) refuse
* without canUse() as forbidden, whatever the ID (review M-9). With the Theme Builder rights, a template the user
* may not edit still reads like a missing one (template(), ruling Q18 – templates are listed by tb-list), while a
* layout without edit_post answers forbidden (layout(): only accounts with the Theme Builder rights get there, and
* tb-list names the layout IDs of their templates). The group's "nur lesen" stops writes (E9). Not final so
* abilities can be tested with a mock.
*/
class ThemeBuilderAccess
{
/** Tool group "Divi Theme Builder" (Tools tab). */
public const GROUP = 'divi-theme-builder';
/**
* @param (Closure(): bool)|null $writable Group writable (neither "nur lesen" nor disabled); null = always.
* @param (Closure(): bool)|null $visible Group not disabled; null = always.
*/
public function __construct(
private DiviThemeBuilder $store,
private DiviRoles $roles,
private ?Closure $writable = null,
private ?Closure $visible = null
) {
}
public function store(): DiviThemeBuilder
{
return $this->store;
}
public function roles(): DiviRoles
{
return $this->roles;
}
public function canUse(): ?WP_Error
{
if (! current_user_can('edit_others_posts')) {
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).');
}
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_theme_options).');
}
return $this->roles->refusal('theme_builder');
}
/** Whether Theme Builder objects exist for this connection: group not disabled and canUse(). */
public function visible(): bool
{
return ($this->visible === null || ($this->visible)()) && $this->canUse() === null;
}
/**
* The Theme Builder access of a global library edit (GlobalUsages, ruling Q17 M1, review I-2): visible = Theme
* Builder posts may be named (visible()); usable = a global edit may change them – the rights of every Theme
* Builder write (canUse(), part of visible()) and a writable group, as the Divi tools on layouts.
*
* @return array{visible: bool, usable: bool}
*/
public function usageState(): array
{
$visible = $this->visible();
return ['visible' => $visible, 'usable' => $visible && $this->writableGate() === null];
}
public function writableGate(): ?WP_Error
{
if ($this->writable === null || ($this->writable)()) {
return null;
}
return Errors::forbidden(
'Schreiben im Divi Theme Builder ist für diese Verbindung abgeschaltet (Gruppe „Divi Theme Builder“ auf '
. '„nur lesen“ oder deaktiviert).',
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
);
}
/**
* Layout $postId for the Divi tools (E1): null when it is no Theme Builder layout (the caller goes on);
* like a missing ID without visible(); forbidden without edit_post; for writing also the group gate and
* a published layout (Divi's TB layouts are always published).
*/
public function layout(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
{
$id = PostGuard::id($postId);
$post = $id === null ? null : get_post($id);
if (! $post instanceof WP_Post || DiviThemeBuilder::areaOf((string) $post->post_type) === null) {
return null;
}
$id = (int) $post->ID;
if (! $this->visible()) {
return PostGuard::notFound($id);
}
if (! current_user_can('edit_post', $id)) {
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Layout %d zu bearbeiten.', $id));
}
if (! $forWrite) {
return $post;
}
$gate = $this->writableGate();
if ($gate !== null) {
return $gate;
}
if ((string) $post->post_status !== 'publish') {
return Errors::invalidInput(
sprintf(
'Theme-Builder-Layout %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
$id,
(string) $post->post_status
),
['post_id' => 'nur aktive Theme-Builder-Layouts']
);
}
return $post;
}
/**
* Template $templateId for the Theme Builder tools. Any other ID, a hidden Theme Builder and a template the
* user may not edit (canEditTemplate) answer identically: not found (ruling Q18, P12 – like tb-list, Q8/B11).
*/
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
{
$id = PostGuard::id($templateId);
if ($id === null) {
return self::invalidTemplateId();
}
$post = get_post($id);
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status !== 'publish'
|| ! $this->canEditTemplate($id)
) {
return Errors::notFound(
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
'Vorlagen mit lit/tb-list auflisten.'
);
}
return $forWrite ? ($this->writableGate() ?? $post) : $post;
}
/** The answer for a template_id that is missing or no ID (template(), lit/tb-list – review M-5). */
public static function invalidTemplateId(): WP_Error
{
return Errors::invalidInput('template_id fehlt oder ist keine ID.', ['template_id' => 'ID aus lit/tb-list']);
}
/**
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
* (template(), a template the user may not edit reads as missing – ruling Q18), no Theme Builder draft (E7),
* never the default template (E8), the current template_rev. $offSwitch (lit/tb-activate with enabled: false,
* ruling Q16): also a template visitors see although it is not published (e.g. in the trash – Divi renders
* every attached, enabled template, B8), so it can always be switched off; any other unpublished template
* still reads as missing. The off-switch also works while the Theme Builder has unsaved changes (ruling Q20).
*
* @param array<string, mixed> $input template_id, template_rev
*/
public function changeableTemplate(array $input, bool $offSwitch = false): Template|WP_Error
{
// Review M-9: without the Theme Builder rights refuse like tb-list/tb-create-template – independent of the ID.
$refusal = $this->canUse();
if ($refusal !== null) {
return $refusal;
}
$post = $this->template($input['template_id'] ?? null, true);
if ($post instanceof WP_Error) {
$id = $offSwitch && Errors::codeOf($post) === 'not_found'
? $this->liveUnpublished($input['template_id'] ?? null)
: null;
if ($id === null) {
return $post;
}
$gate = $this->writableGate();
return $gate ?? $this->checkedTemplate($id, $input, true);
}
return $this->checkedTemplate((int) $post->ID, $input, $offSwitch);
}
/**
* The templates of the live Theme Builder post split by canEditTemplate() (ruling Q21 – one place for
* tb-list and the dry runs of tb-assign/tb-activate): only "templates" may be shown; "hidden" serves counts
* ("weitere aktive Vorlagen ohne Berechtigung"), never its IDs, titles or conditions (Q8/B11, P12).
*
* @return array{templates: list<Template>, hidden: list<Template>}
*/
public function editableLiveTemplates(): array
{
$split = ['templates' => [], 'hidden' => []];
foreach ($this->store->liveTemplates() as $template) {
$split[$this->canEditTemplate($template->id) ? 'templates' : 'hidden'][] = $template;
}
return $split;
}
/**
* Whether template $id may be named to this user (tb-list shows other IDs not at all, ruling Q8/B11).
* $id must come from Divi's own template list (templateIds()/liveTemplates()/detachedIds()): this checks
* only the rights, not that $id is a template.
*/
public function canEditTemplate(int $id): bool
{
return $this->visible() && current_user_can('edit_post', $id);
}
/**
* Refusal when a layout $template refers to is no longer a published layout of its area (Divi trashes
* layouts marked unused after seven days, stage3b README finding 10); reattach and activation need this
* first (ruling Q8/B10). Areas without a layout (ID 0) need nothing.
*/
public function layoutsRefusal(Template $template): ?WP_Error
{
$fields = [];
foreach ($template->layouts as $area => $layout) {
if ($layout['id'] <= 0) {
continue;
}
$post = get_post($layout['id']);
if (
! $post instanceof WP_Post
|| $post->post_type !== (DiviThemeBuilder::LAYOUT_TYPES[$area] ?? null)
|| (string) $post->post_status !== 'publish'
) {
$fields['layouts.' . $area] = sprintf(
'Layout %d ist kein aktives Theme-Builder-Layout (%s) mehr',
$layout['id'],
$area
);
}
}
if ($fields === []) {
return null;
}
return Errors::invalidInput(
sprintf(
'Theme-Builder-Vorlage %d verweist auf Layouts, die nicht mehr aktiv sind; nichts wurde gespeichert.',
$template->id
),
$fields,
'Im Theme Builder (Divi → Theme Builder) die Layouts der Vorlage wiederherstellen oder ersetzen, '
. 'dann erneut aufrufen.'
);
}
/**
* Creating a template with layouts: canUse(), the group gate and publish_posts of the template and
* layout types – Divi publishes them at once (F17, F18).
*/
public function canCreate(): ?WP_Error
{
$refusal = $this->canUse() ?? $this->writableGate();
if ($refusal !== null) {
return $refusal;
}
foreach (array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)) as $type) {
$object = get_post_type_object($type);
$cap = is_object($object) && isset($object->cap->publish_posts)
? (string) $object->cap->publish_posts
: 'publish_posts';
if (! current_user_can($cap)) {
return Errors::forbidden(
sprintf('Keine Berechtigung, Inhalte des Theme Builders zu veröffentlichen (%s).', $cap)
);
}
}
return null;
}
/** E7: while the Theme Builder has unsaved changes (draft), its later save would replace the template list. */
public function draftRefusal(): ?WP_Error
{
if ($this->store->draftId() <= 0) {
return null;
}
return Errors::unsupported(
'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); solange ändert LIT MCP keine Vorlagen – '
. 'ein späteres Speichern dort würde die Zuordnung ersetzen. Nichts wurde gespeichert.',
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
);
}
/**
* ID of a template that is not published but live (listed by the live Theme Builder post and enabled) and the
* user may edit; null otherwise – the raw lookup of the off-switch (ruling Q16).
*/
private function liveUnpublished(mixed $templateId): ?int
{
$id = PostGuard::id($templateId);
$post = $id === null ? null : get_post($id);
if (
! $post instanceof WP_Post
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|| (string) $post->post_status === 'publish'
|| ! $this->canEditTemplate((int) $post->ID)
|| ! in_array((int) $post->ID, $this->store->templateIds($this->store->liveId()), true)
) {
return null;
}
return $this->store->template((int) $post->ID, true)?->isLive() === true ? (int) $post->ID : null;
}
/**
* The draft, default and template_rev checks of changeableTemplate() for template $id (rights already checked).
* The off-switch skips the draft refusal (ruling Q20): hiding a template must always be possible; the caller
* warns that saving an open Theme Builder editor may restore its own state.
*
* @param array<string, mixed> $input
*/
private function checkedTemplate(int $id, array $input, bool $offSwitch): Template|WP_Error
{
$refusal = $offSwitch ? null : $this->draftRefusal();
if ($refusal !== null) {
return $refusal;
}
$attached = in_array($id, $this->store->templateIds($this->store->liveId()), true);
$template = $this->store->template($id, $attached);
if ($template === null) {
return Errors::notFound(
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
'Vorlagen mit lit/tb-list auflisten.'
);
}
if ($template->default) {
return Errors::unsupported(
'Die Standardvorlage ändert LIT MCP nicht; nichts wurde gespeichert.',
'Die Standardvorlage im Divi Theme Builder bearbeiten.'
);
}
if (! is_string($input['template_rev'] ?? null) || $input['template_rev'] !== $template->rev()) {
return Errors::revMismatch(
'Die Vorlage wurde seit dem Lesen geändert (template_rev passt nicht); nichts wurde gespeichert.',
'Mit lit/tb-list neu lesen und mit der neuen template_rev wiederholen.'
);
}
return $template;
}
}