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
This commit is contained in:
2026-09-27 23:12:14 +02:00
co-authored by Claude Opus 5.5
parent e7bbe1e9d7
commit 14bc954ecc
10 changed files with 176 additions and 18 deletions
@@ -26,7 +26,9 @@ defined('ABSPATH') || exit;
* 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).
* answer and the change log; a failed write restores them (and undoes the reattach). The undo does not put back
* Divi's "unused" marks a reattach removed (review M-12 c): the template is detached again, but Divi no longer
* trashes it after seven days – harmless, it stays under detached in lit/tb-list.
*/
final class TbAssignAbility implements Ability
{
@@ -216,11 +216,14 @@ final class TbCreateTemplateAbility implements Ability
return $plan;
}
/** The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11). */
/**
* The default template, only when the user may edit it (ruling Q19, like tb-list Q8/B11) – from the one shared
* split of the live templates (ThemeBuilderAccess::editableLiveTemplates(), ruling Q21, review M-11).
*/
private function defaultTemplate(): ?Template
{
foreach ($this->access->store()->liveTemplates() as $template) {
if ($template->default && $this->access->canEditTemplate($template->id)) {
foreach ($this->access->editableLiveTemplates()['templates'] as $template) {
if ($template->default) {
return $template;
}
}
@@ -26,6 +26,9 @@ final class TbListAbility implements Ability
{
public const DETACHED_LIMIT = 50;
/** At most this many detached IDs are read to find DETACHED_LIMIT the user may edit (review M-4). */
public const DETACHED_SCAN = 500;
public function __construct(private ThemeBuilderAccess $access, private TemplateConditions $conditions)
{
}
@@ -110,18 +113,29 @@ final class TbListAbility implements Ability
$split = $this->access->editableLiveTemplates();
$live = $split['templates'];
$hiddenLive = count(array_filter($split['hidden'], static fn (Template $t): bool => $t->isLive()));
// Review M-4: the limit counts only templates the user may edit; foreign ones do not crowd them out.
$detached = [];
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
$detachedIds = $store->detachedIds(self::DETACHED_SCAN);
$moreDetached = count($detachedIds) >= self::DETACHED_SCAN;
foreach ($detachedIds as $id) {
$template = $store->template($id, false);
if ($template !== null && $this->access->canEditTemplate($template->id)) {
$detached[] = $template;
if ($template === null || ! $this->access->canEditTemplate($template->id)) {
continue;
}
if (count($detached) >= self::DETACHED_LIMIT) {
$moreDetached = true;
break;
}
$detached[] = $template;
}
// Only what the user may see counts for shared_with (B11).
$shared = array_merge($live, $detached);
if (isset($input['template_id'])) {
$id = (int) PostGuard::id($input['template_id']);
$id = PostGuard::id($input['template_id']);
if ($id === null) {
return ThemeBuilderAccess::invalidTemplateId();
}
$only = static fn (array $list): array => array_values(
array_filter($list, static fn (Template $template): bool => $template->id === $id)
);
@@ -149,12 +163,31 @@ final class TbListAbility implements Ability
$hiddenLive
);
}
$trashed = array_values(array_filter(
$live,
static fn (Template $t): bool => $t->isLive() && $t->status !== 'publish'
));
if ($trashed !== []) {
// Review M-10: tb-assign and switching on read such a template as missing (ThemeBuilderAccess::template()).
$notes[] = sprintf(
'Aktiv, aber nicht veröffentlicht (z. B. im Papierkorb, siehe status): %s – nur deaktivieren '
. '(lit/tb-activate enabled: false); Bedingungen ändern oder erneut aktivieren geht erst, wenn '
. 'die Vorlage in WordPress wiederhergestellt ist.',
implode(', ', array_map(static fn (Template $t): string => (string) $t->id, $trashed))
);
}
if ($detached !== []) {
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt – oder jemand '
. 'hat sie dort bewusst gelöscht; Divi löscht als ungenutzt markierte Vorlagen und Layouts 7 Tage '
. 'nach der Markierung. Nur auf ausdrücklichen Wunsch wieder zuordnen (bleibt deaktiviert): '
. 'lit/tb-assign mit reattach: true.';
}
if ($moreDetached && ! isset($input['template_id'])) {
$notes[] = sprintf(
'Weitere entfernte Vorlagen nicht aufgeführt (höchstens %d je Aufruf).',
self::DETACHED_LIMIT
);
}
$result = [
'theme_builder_id' => $store->liveId(),
@@ -119,6 +119,7 @@ final class Template
return [
'id' => $this->id,
'title' => $this->title,
'status' => $this->status,
'default' => $this->default,
'enabled' => $this->enabled,
'live' => $this->isLive(),
@@ -18,6 +18,9 @@ defined('ABSPATH') || exit;
* of that type with read_post (not trashed, no auto-draft/attachment status), a term of that taxonomy with
* assign_term, a user only with list_users (ruling Q10, stricter than Divi), an existing role. Refusals for
* missing and unreadable objects read the same and never name an object's title (P12). Nothing is written here.
* Every condition ID is limited to letters, digits, "_", ":" and "-" (at most 200 characters): a role slug with
* other characters (possible with role plugins) is refused as an invalid condition ID and must be set in Divi's
* Theme Builder (review M-6).
*/
final class TemplateConditions
{
@@ -19,7 +19,12 @@ defined('ABSPATH') || exit;
* 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 group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock.
* (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
{
@@ -146,10 +151,7 @@ class ThemeBuilderAccess
{
$id = PostGuard::id($templateId);
if ($id === null) {
return Errors::invalidInput(
'template_id fehlt oder ist keine ID.',
['template_id' => 'ID aus lit/tb-list']
);
return self::invalidTemplateId();
}
$post = get_post($id);
@@ -168,6 +170,12 @@ class ThemeBuilderAccess
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),
@@ -180,6 +188,12 @@ class ThemeBuilderAccess
*/
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'