failedOn() replaced the hint for a failure after earlier pages were replaced, so the "Sicherung X liegt im Papierkorb" sentence of the failed page's backup was lost. The hint now leads with it when a backup exists. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
671 lines
28 KiB
PHP
671 lines
28 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\VisibleEdits;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||
use LIT\MCP\Log;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-make-global (spec §5.7, E10): a node becomes a global module – a new global library entry holding
|
||
* exactly its bytes (NewLibraryEntry, the lit/library-save path: rights with scope "global", ruling P7,
|
||
* StoredNodeGuard, no reference to any entry in any spelling, ruling Q7) and the node replaced on its page by the
|
||
* instance the Visual Builder writes (GlobalInstance::of). All or nothing: the page is written through
|
||
* DiviWriter::commit() (tree_rev lock, revision); when it is refused or rolled back, the new entry is deleted
|
||
* again (NewDraft) – only a page write that may have been saved (partial) keeps the entry, because the page may
|
||
* already show it. Sources are pages and posts only (post types page and post, ruling Q25): no library entries
|
||
* (no nesting, ruling P11), no Theme Builder layouts (ruling Q24; there lit/library-save and lit-library-insert)
|
||
* and no other Divi-enabled types. Needs the rights of
|
||
* lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2).
|
||
*
|
||
* Second mode (E10, answer F2): library_id plus targets replaces further occurrences – only nodes whose content
|
||
* fingerprint and normalized bytes (Fingerprint::canonical, ruling Q25: visible text parts included) equal the
|
||
* entry's root ("identical" in lit/library-find-similar) – by instances of that active
|
||
* global entry (one readable root, no reference to any entry in any spelling, ruling Q7). A global edit: every
|
||
* target is checked first (edit_post and tree_rev through DiviWriter::page(), P8a – the account changes only pages
|
||
* it may edit; identical nodes; the instance passes the page's StoredNodeGuard; post types page and post only),
|
||
* then a dry run lists the pages (tree_rev, status, live – drafts are listed, like P8a), and only confirm: true
|
||
* writes. Every page goes through DiviWriter::commit() with its own tree_rev
|
||
* lock, revision and change-log row; a live page (publish, private, future) without revisions first gets
|
||
* VisibleEdits' backup, refused for every page before anything is written where it could not be faithful (P9).
|
||
* Pages are written in the given order; when page N fails, pages 1..N-1 stay replaced (each with its restore
|
||
* point), page N is rolled back by DiviSaver (or reported partial), pages after N are not attempted – the error
|
||
* names all three groups (changed_posts with pages, failed_post, unchanged_posts; D10, S10).
|
||
*/
|
||
final class LibraryMakeGlobalAbility implements Ability
|
||
{
|
||
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
|
||
. 'entfernt.';
|
||
|
||
public const MAX_TARGETS = 20;
|
||
|
||
public const MAX_IDS = 20;
|
||
|
||
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
|
||
|
||
/** Post types that are sources and targets (ruling Q25): pages and posts, no other Divi-enabled type. */
|
||
public const POST_TYPES = ['page', 'post'];
|
||
|
||
/** Inputs of the first mode, refused next to library_id. */
|
||
private const NODE_INPUT = ['post_id', 'tree_rev', 'id', 'title', 'categories', 'tags'];
|
||
|
||
private VisibleEdits $visible;
|
||
|
||
/**
|
||
* @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled (audit M2); null = always.
|
||
* @param VisibleEdits|null $visible Backup of live pages without revisions; null = the default.
|
||
*/
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviWriter $writer,
|
||
private NewLibraryEntry $entries,
|
||
private ChangeLog $log,
|
||
private ?Closure $diviWritable = null,
|
||
?VisibleEdits $visible = null,
|
||
private Parser $parser = new Parser()
|
||
) {
|
||
$this->visible = $visible ?? new VisibleEdits();
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-make-global';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'In globales Modul umwandeln';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) auf einer Seite oder '
|
||
. 'in einem Beitrag (Inhaltstypen page und post) in ein globales Modul um: legt einen globalen '
|
||
. 'Bibliothekseintrag mit genau diesem Inhalt an und ersetzt den Knoten durch die verknüpfte Instanz – '
|
||
. 'alles oder nichts. Danach zeigt die '
|
||
. 'Seite den Inhalt aus der Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.'
|
||
. ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids '
|
||
. 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"; nur Seiten und Beiträge); das '
|
||
. 'ist zuerst ein Probelauf mit den betroffenen Seiten und braucht dann confirm: true. Jede Seite bekommt '
|
||
. 'eine Revision.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(DiviSchema::write(), [
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
'library_id' => [
|
||
'type' => 'integer',
|
||
'minimum' => 1,
|
||
'description' => 'Globaler Eintrag, dessen inhaltsgleiche Vorkommen ersetzt werden (mit targets).',
|
||
],
|
||
'targets' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'maxItems' => self::MAX_TARGETS,
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'tree_rev' => ['type' => 'string'],
|
||
'ids' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'maxItems' => self::MAX_IDS,
|
||
'items' => ['type' => 'string'],
|
||
],
|
||
],
|
||
'required' => ['post_id', 'tree_rev', 'ids'],
|
||
'additionalProperties' => false,
|
||
],
|
||
],
|
||
]),
|
||
'required' => [],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'library_id' => ['type' => 'integer'],
|
||
'tree_rev' => ['type' => 'string'],
|
||
'pages' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
], ['library_id']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->precheck($input) ?? true;
|
||
}
|
||
|
||
/** @throws WriteFailed */
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
return self::replaces($input) ? $this->replaceOccurrences($input) : $this->makeGlobal($input);
|
||
}
|
||
|
||
/** @param array<string, mixed> $input Whether the call replaces occurrences (second mode). */
|
||
private static function replaces(array $input): bool
|
||
{
|
||
return array_key_exists('library_id', $input) || array_key_exists('targets', $input);
|
||
}
|
||
|
||
/**
|
||
* Rights of lit-library-insert (add_library, library group writable) and – for a new entry only – of
|
||
* lit/library-save with scope "global" (publish_posts, save_library, edit_global_library), and
|
||
* lit-divi-insert's Tools-tab state (M2).
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function precheck(array $input): ?WP_Error
|
||
{
|
||
$refusal = $this->access->canInsert() ?? (self::replaces($input) ? null : $this->access->canSave(true));
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
|
||
return Errors::unsupported(
|
||
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
|
||
. 'lit-divi-insert deaktiviert).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function makeGlobal(array $input): array|WP_Error
|
||
{
|
||
$page = $this->writer->page($input);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
|
||
$refusal = self::sourceRefusal($page);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
// LibraryItemSpec::forNode() refuses instances and nodes holding one, NewLibraryEntry every other
|
||
// spelling of a reference (rulings P11, Q7, B17) – one rule, no copy here.
|
||
$spec = $this->entries->spec($page, $node, $input, true, $this->writer->composition($page)->storedGuard());
|
||
if ($spec instanceof WP_Error) {
|
||
return $spec;
|
||
}
|
||
|
||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||
$created = $this->entries->create($spec, $node, $draft);
|
||
$libraryId = $created['id'];
|
||
|
||
$root = $node->copy();
|
||
$root->unifyLineBreaks();
|
||
$instance = GlobalInstance::of($root, $libraryId);
|
||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||
$change = (new DiviChange(sprintf(
|
||
'Knoten %s in das globale Modul „%s“ (%d) umgewandelt',
|
||
$id,
|
||
$spec->title,
|
||
$libraryId
|
||
)))->record($instance, 'replaced');
|
||
|
||
try {
|
||
$result = $this->writer->commit($this->name(), $page, $change, $input);
|
||
} catch (WriteFailed $e) {
|
||
if (! $e->rolledBack) {
|
||
// The page may already show the instance: deleting the entry would leave it empty.
|
||
throw $e->withDetails(
|
||
['library_id' => $libraryId],
|
||
sprintf(
|
||
'Den Aufruf nicht einfach wiederholen. Der neue globale Eintrag %d bleibt bestehen, weil die '
|
||
. 'Seite ihn möglicherweise schon zeigt; den Stand mit lit-divi-outline prüfen und dem '
|
||
. 'Nutzer Bescheid geben.',
|
||
$libraryId
|
||
)
|
||
);
|
||
}
|
||
$draft->discard($libraryId, $e, self::REASON_PAGE);
|
||
}
|
||
|
||
if ($result instanceof WP_Error) {
|
||
return $draft->refuse($libraryId, $result);
|
||
}
|
||
|
||
return $this->result($result, $spec, $libraryId, $created['warnings'], $page, $input);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function replaceOccurrences(array $input): array|WP_Error
|
||
{
|
||
$mixed = array_values(array_intersect(self::NODE_INPUT, array_keys($input)));
|
||
if ($mixed !== [] || ! array_key_exists('library_id', $input) || ! array_key_exists('targets', $input)) {
|
||
return Errors::invalidInput(
|
||
'Zum Ersetzen library_id und targets angeben (ohne post_id, id und title); nichts wurde gespeichert.',
|
||
['library_id' => 'library_id und targets zusammen, ohne ' . implode(', ', $mixed ?: self::NODE_INPUT)]
|
||
);
|
||
}
|
||
|
||
$root = $this->globalRoot($input['library_id']);
|
||
if ($root instanceof WP_Error) {
|
||
return $root;
|
||
}
|
||
[$libraryId, $block] = $root;
|
||
|
||
$plans = $this->plans($libraryId, $block, $input['targets']);
|
||
if ($plans instanceof WP_Error) {
|
||
return $plans;
|
||
}
|
||
|
||
$affected = array_map(static fn (array $plan): array => [
|
||
'post_id' => (int) $plan[0]->post->ID,
|
||
'title' => (string) $plan[0]->post->post_title,
|
||
'status' => (string) $plan[0]->post->post_status,
|
||
'live' => self::isLive($plan[0]->post),
|
||
'tree_rev' => $plan[0]->treeRev(),
|
||
'ids' => array_keys($plan[1]),
|
||
], $plans);
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
$live = count(array_filter(array_column($affected, 'live')));
|
||
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'%d Vorkommen auf %d %s (%d davon veröffentlicht, privat oder geplant) würden durch das globale '
|
||
. 'Modul %d ersetzt; die Seiten zeigen danach den Inhalt aus der Bibliothek. Nichts wurde '
|
||
. 'gespeichert (Probelauf).',
|
||
array_sum(array_map(static fn (array $row): int => count($row['ids']), $affected)),
|
||
count($affected),
|
||
count($affected) === 1 ? 'Seite' : 'Seiten',
|
||
$live,
|
||
$libraryId
|
||
),
|
||
['affected' => $affected, 'affected_count' => count($affected), 'live_count' => $live]
|
||
);
|
||
}
|
||
|
||
foreach ($plans as [$page]) {
|
||
$refusal = self::isLive($page->post) ? $this->visible->backupRefusal(
|
||
$page->post,
|
||
sprintf('Seite %d', (int) $page->post->ID),
|
||
'Revisionen aktivieren (WP_POST_REVISIONS) oder die Seite im Divi-Builder bearbeiten.'
|
||
) : null;
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
}
|
||
|
||
return $this->replace($libraryId, $block, $plans);
|
||
}
|
||
|
||
/**
|
||
* Every target checked before anything is written: page rights and tree_rev (DiviWriter::page(), P8a),
|
||
* pages and posts only, each once, identical nodes, the instance through the page's StoredNodeGuard.
|
||
*
|
||
* @return list<array{0: DiviPage, 1: array<string, Block>}>|WP_Error
|
||
*/
|
||
private function plans(int $libraryId, Block $root, mixed $targets): array|WP_Error
|
||
{
|
||
$targets = is_array($targets) ? array_values($targets) : [];
|
||
if ($targets === [] || count($targets) > self::MAX_TARGETS) {
|
||
return Errors::invalidInput(
|
||
sprintf('targets fehlt oder hat mehr als %d Seiten; nichts wurde gespeichert.', self::MAX_TARGETS),
|
||
['targets' => sprintf('1 bis %d Seiten', self::MAX_TARGETS)]
|
||
);
|
||
}
|
||
|
||
$fingerprint = Fingerprint::content($root);
|
||
$canonical = Fingerprint::canonical($root);
|
||
$plans = [];
|
||
$errors = [];
|
||
$seen = [];
|
||
foreach ($targets as $i => $target) {
|
||
$target = is_array($target) ? $target : [];
|
||
$page = $this->writer->page([
|
||
'post_id' => $target['post_id'] ?? null,
|
||
'tree_rev' => $target['tree_rev'] ?? null,
|
||
]);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
$postId = (int) $page->post->ID;
|
||
if (self::sourceRefusal($page) !== null || isset($seen[$postId])) {
|
||
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge (page, post), jede einmal (andere Einträge '
|
||
. 'einzeln mit lit-library-insert)';
|
||
continue;
|
||
}
|
||
$seen[$postId] = true;
|
||
|
||
$ids = is_array($target['ids'] ?? null) ? array_values($target['ids']) : [];
|
||
if ($ids === [] || count($ids) > self::MAX_IDS) {
|
||
$errors["targets.$i.ids"] = sprintf('1 bis %d Pfad-IDs', self::MAX_IDS);
|
||
}
|
||
$nodes = [];
|
||
foreach ($ids as $j => $id) {
|
||
$node = is_string($id) ? $page->index->find($id) : null;
|
||
// The hash first (cheap), then the normalized bytes themselves (ruling Q25) – no collision counts.
|
||
$identical = $node !== null && ! $node->isOpaque() && Fingerprint::content($node) === $fingerprint
|
||
&& Fingerprint::canonical($node) === $canonical;
|
||
if (! $identical) {
|
||
$errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" '
|
||
. 'aus lit/library-find-similar)';
|
||
continue;
|
||
}
|
||
$nodes[(string) $id] = $node;
|
||
}
|
||
|
||
$guard = $this->writer->composition($page)->storedGuard();
|
||
$problems = $guard->problems(GlobalInstance::of($root, $libraryId));
|
||
if ($problems !== []) {
|
||
$errors["targets.$i.post_id"] = implode('; ', $problems);
|
||
}
|
||
$plans[] = [$page, $nodes];
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Nicht alle Ziele lassen sich ersetzen; nichts wurde gespeichert.', $errors);
|
||
}
|
||
|
||
return $plans;
|
||
}
|
||
|
||
/**
|
||
* Writes the pages in order (see the class comment for a failure on page N).
|
||
*
|
||
* @param list<array{0: DiviPage, 1: array<string, Block>}> $plans
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function replace(int $libraryId, Block $root, array $plans): array
|
||
{
|
||
$pages = [];
|
||
$warnings = [];
|
||
$changed = [];
|
||
foreach ($plans as $n => [$page, $nodes]) {
|
||
$postId = (int) $page->post->ID;
|
||
$change = new DiviChange(
|
||
sprintf('%d Vorkommen durch das globale Modul %d ersetzt', count($nodes), $libraryId)
|
||
);
|
||
foreach ($nodes as $node) {
|
||
$instance = GlobalInstance::of($root, $libraryId);
|
||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||
$change->record($instance, 'replaced');
|
||
}
|
||
|
||
$rest = array_map(static fn (array $plan): int => (int) $plan[0]->post->ID, array_slice($plans, $n + 1));
|
||
$backup = [];
|
||
try {
|
||
$backup = self::isLive($page->post) ? $this->visible->backup($page->post) : [];
|
||
$saved = $this->writer->commit(
|
||
$this->name(),
|
||
$page,
|
||
$change,
|
||
['post_id' => $postId, 'tree_rev' => $page->treeRev(), 'library_id' => $libraryId]
|
||
);
|
||
if ($saved instanceof WP_Error) {
|
||
throw WriteFailed::rolledBack(Errors::clientMessage($saved), detail: 'commit refused the page');
|
||
}
|
||
} catch (WriteFailed $e) {
|
||
$e = VisibleEdits::keepingBackup($e, $backup['backup_id'] ?? null, 'der Seite');
|
||
throw self::failedOn($postId, $e, $pages, $rest);
|
||
} catch (Throwable $e) {
|
||
Log::failure(sprintf('%s on post %d', $this->name(), $postId), $e);
|
||
$unknown = WriteFailed::partial(
|
||
'Interner Fehler beim Speichern dieser Seite (Details im PHP-Error-Log der Website).',
|
||
$e,
|
||
postId: $postId
|
||
);
|
||
throw self::failedOn($postId, $unknown, $pages, $rest);
|
||
}
|
||
|
||
$pages[] = array_merge([
|
||
'post_id' => $postId,
|
||
'tree_rev' => (string) ($saved['tree_rev'] ?? ''),
|
||
'revision_id' => $saved['revision_id'] ?? null,
|
||
], $backup);
|
||
$post = $page->post;
|
||
$changed[] = [(string) $postId, (string) $post->post_type, 'replaced', (string) $post->post_title];
|
||
foreach (is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [] as $warning) {
|
||
$warnings[] = sprintf('Eintrag %d: %s', $postId, (string) $warning);
|
||
}
|
||
}
|
||
|
||
$tool = ToolResult::ok(sprintf(
|
||
'Vorkommen auf %d %s durch das globale Modul %d ersetzt (je mit Revision).',
|
||
count($pages),
|
||
count($pages) === 1 ? 'Seite' : 'Seiten',
|
||
$libraryId
|
||
));
|
||
foreach ($changed as $entry) {
|
||
$tool->changed(...$entry);
|
||
}
|
||
|
||
return $tool->warnings($warnings)
|
||
->with('library_id', $libraryId)
|
||
->with('pages', $pages)
|
||
->toArray();
|
||
}
|
||
|
||
/**
|
||
* The failure of page $postId with the state of the whole call: pages already replaced (each with its
|
||
* revision or backup), the failed one, the ones not attempted.
|
||
*
|
||
* @param list<array<string, mixed>> $pages Pages replaced before.
|
||
* @param list<int> $rest Pages after the failed one.
|
||
*/
|
||
private static function failedOn(int $postId, WriteFailed $e, array $pages, array $rest): WriteFailed
|
||
{
|
||
$done = array_map(static fn (array $row): int => (int) $row['post_id'], $pages);
|
||
$details = ['changed_posts' => $done, 'pages' => $pages, 'failed_post' => $postId, 'unchanged_posts' => $rest];
|
||
if ($done === []) {
|
||
return $e->withDetails($details);
|
||
}
|
||
|
||
// DiviSaver's own state stays (e.g. "Revision X bleibt erhalten").
|
||
$state = sprintf(
|
||
'Seite %d: %s Bereits ersetzt (je mit Revision): %s; unverändert: %s.',
|
||
$postId,
|
||
$e->state ?? 'Die Änderung wurde nicht gespeichert.',
|
||
implode(', ', $done),
|
||
$rest === [] ? 'keine weiteren' : implode(', ', $rest)
|
||
);
|
||
|
||
// Ruling P14: a backup of page N (revisions off) stays named – its sentence leads the hint.
|
||
$hint = 'Den Stand mit lit/library-find-similar neu prüfen und nur die übrigen Seiten mit neuem tree_rev '
|
||
. 'ersetzen.';
|
||
if (isset($e->details['backup_id']) && $e->hint !== null) {
|
||
$hint = $e->hint . ' ' . $hint;
|
||
}
|
||
|
||
return $e->withDetails($details, $hint, $e->rolledBack ? $state : null);
|
||
}
|
||
|
||
/** Pages visitors see (or will see): changes there get VisibleEdits' restore point (P8a statuses). */
|
||
private static function isLive(WP_Post $post): bool
|
||
{
|
||
return in_array((string) $post->post_status, GlobalUsages::LIVE, true);
|
||
}
|
||
|
||
/**
|
||
* The active global entry $libraryId (LibraryAccess::item(): the library right and edit_post on it) with
|
||
* its single readable root, holding no global module and naming no entry in any spelling (P11, Q7).
|
||
*
|
||
* @return array{0: int, 1: Block}|WP_Error
|
||
*/
|
||
private function globalRoot(mixed $libraryId): array|WP_Error
|
||
{
|
||
$id = PostGuard::id($libraryId);
|
||
if ($id === null) {
|
||
return Errors::invalidInput('library_id ist keine ID; nichts wurde gespeichert.', [
|
||
'library_id' => 'ID aus lit/library-list',
|
||
]);
|
||
}
|
||
|
||
$item = $this->access->item($id, false);
|
||
if ($item === null) {
|
||
return PostGuard::notFound($id);
|
||
}
|
||
if ($item instanceof WP_Error) {
|
||
return $item;
|
||
}
|
||
|
||
$content = (string) $item->post_content;
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, $content));
|
||
} catch (MalformedMarkup) {
|
||
$roots = [];
|
||
}
|
||
$valid = (string) $item->post_status === 'publish' && $this->access->isGlobal($item) && count($roots) === 1
|
||
&& ! $roots[0]->isOpaque() && ! LibraryContent::containsGlobal($roots[0])
|
||
&& ! UsageMatcher::referencesAny($content);
|
||
if (! $valid) {
|
||
return Errors::invalidInput(
|
||
sprintf(
|
||
'Bibliothekseintrag %d ist kein aktiver globaler Eintrag mit genau einem Knoten ohne Verweis auf '
|
||
. 'andere Einträge; nichts wurde gespeichert.',
|
||
$id
|
||
),
|
||
['library_id' => 'globaler Eintrag (lit/library-list, scope "global")']
|
||
);
|
||
}
|
||
|
||
return [$id, $roots[0]];
|
||
}
|
||
|
||
/**
|
||
* Only pages and posts (POST_TYPES, ruling Q25): a library entry would nest global modules (P11), a Theme
|
||
* Builder layout is left to lit/library-save plus lit-library-insert (ruling Q24), other Divi-enabled types
|
||
* are out of scope.
|
||
*/
|
||
private static function sourceRefusal(DiviPage $page): ?WP_Error
|
||
{
|
||
if ($page->isLibraryItem()) {
|
||
return Errors::invalidInput(
|
||
'Knoten in Bibliothekseinträgen lassen sich nicht in globale Module umwandeln (keine '
|
||
. 'Verschachtelung); nichts wurde angelegt.',
|
||
['post_id' => 'eine Seite oder ein Beitrag']
|
||
);
|
||
}
|
||
|
||
if ($page->isThemeBuilderLayout()) {
|
||
return Errors::invalidInput(
|
||
'Knoten in Theme-Builder-Layouts wandelt lit/library-make-global nicht um; nichts wurde angelegt.',
|
||
['post_id' => 'eine Seite oder ein Beitrag – im Layout mit lit/library-save (scope "global") und '
|
||
. 'lit-library-insert (mode "global")']
|
||
);
|
||
}
|
||
|
||
if (! in_array((string) $page->post->post_type, self::POST_TYPES, true)) {
|
||
return Errors::invalidInput(
|
||
'lit/library-make-global arbeitet nur auf Seiten und Beiträgen; nichts wurde gespeichert.',
|
||
['post_id' => 'eine Seite oder ein Beitrag (Inhaltstyp page oder post)']
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* The page's Divi write result plus the new entry: lit_mcp_after_save and a change-log row for the entry
|
||
* (like lit/library-save), library_id and the hint on further occurrences.
|
||
*
|
||
* @param array<string, mixed> $saved
|
||
* @param list<string> $entryWarnings
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function result(
|
||
array $saved,
|
||
LibraryItemSpec $spec,
|
||
int $libraryId,
|
||
array $entryWarnings,
|
||
DiviPage $page,
|
||
array $input
|
||
): array {
|
||
$entry = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $entryWarnings))
|
||
->afterSave($input)
|
||
->log(DiviLibraryStore::POST_TYPE, sprintf(
|
||
'Bibliothek: „%s“ angelegt (%s, global) aus Eintrag %d und dort als Instanz eingesetzt',
|
||
$spec->title,
|
||
self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType,
|
||
(int) $page->post->ID
|
||
));
|
||
// The entry's warnings, including those of its follow-up (listener, change log).
|
||
$warnings = $entry->result('', null)->toArray()['warnings'];
|
||
|
||
$saved['library_id'] = $libraryId;
|
||
$saved['warnings'] = array_merge(
|
||
is_array($saved['warnings'] ?? null) ? $saved['warnings'] : [],
|
||
$warnings,
|
||
[sprintf(
|
||
'Gleichartige Vorkommen: lit/library-find-similar mit library_id %d; inhaltsgleiche („identical“) '
|
||
. 'mit lit/library-make-global (library_id, targets) durch das globale Modul ersetzen.',
|
||
$libraryId
|
||
)]
|
||
);
|
||
|
||
return $saved;
|
||
}
|
||
}
|