feat: lit/tb-assign – conditions with dry run on live templates, reattach

- ThemeBuilderAccess::changeableTemplate(): rights (Q18 not_found), group gate,
  no TB draft, never the default template, current template_rev
- omitted exclude_from stays, [] clears (Q3); live dry run shows use_on and
  exclude_from before/after, overlaps and possible overlaps (B9)
- reattach only with published layouts (B10) and a live TB post; always
  disabled, unmarks template and layouts; failure restores everything

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 16:28:27 +02:00
co-authored by Claude Opus 5.5
parent 2827fd4bd8
commit 0975b2f69b
8 changed files with 837 additions and 1 deletions
@@ -145,6 +145,75 @@ final class Template
return $overlaps;
}
/**
* Live templates other than $exceptId whose conditions are not identical to one of $useOn but may cover
* the same pages (ruling Q8/B9 – "kann sich überschneiden"): same family, e.g. all pages vs. one page,
* all archives vs. one category archive, posts in a category vs. any single post, the front page vs. pages.
* Deliberately generous – the dry run is the user's only preview. Identical conditions are overlaps().
*
* @param list<Template> $live
* @param list<string> $useOn
* @return list<array{id: int, title: string, conditions: list<string>}>
*/
public static function possibleOverlaps(array $live, int $exceptId, array $useOn): array
{
$overlaps = [];
foreach ($live as $other) {
if ($other->id === $exceptId || ! $other->isLive()) {
continue;
}
$conditions = [];
foreach ($other->useOn as $theirs) {
foreach ($useOn as $ours) {
if ($theirs !== $ours && self::mayOverlap($theirs, $ours)) {
$conditions[] = $theirs;
break;
}
}
}
if ($conditions !== []) {
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $conditions];
}
}
return $overlaps;
}
private static function mayOverlap(string $a, string $b): bool
{
foreach (self::scopesOf($a) as $x) {
foreach (self::scopesOf($b) as $y) {
if ($x === $y || str_starts_with($x, $y . ':') || str_starts_with($y, $x . ':')) {
return true;
}
}
}
return false;
}
/**
* The page families a condition covers (Divi's IDs, theme-builder.php:689-1060): singular:post_type:<type>,
* archive:taxonomy:<taxonomy>, archive:post_type:<type>, archive:user, archive:date; "<kind>:all" and posts
* in a term (singular:taxonomy:…) the whole kind; the front page may be a page or the posts archive.
*
* @return list<string>
*/
private static function scopesOf(string $condition): array
{
if ($condition === 'homepage') {
return ['homepage', 'singular:post_type:page', 'archive:post_type:post'];
}
$parts = explode(':', $condition);
$second = $parts[1] ?? '';
if (($parts[0] === 'singular' && $second === 'taxonomy') || $second === 'all') {
return [$parts[0]];
}
return [implode(':', array_slice($parts, 0, in_array($second, ['post_type', 'taxonomy'], true) ? 3 : 2))];
}
/** @return list<string> */
private static function strings(mixed $list): array
{