- 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
383 lines
15 KiB
PHP
383 lines
15 KiB
PHP
<?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;
|
||
}
|
||
}
|