'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 $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 $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 $input * @return array|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 $input * @return array|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}>|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}> $plans * @return array * @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> $pages Pages replaced before. * @param list $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 $saved * @param list $entryWarnings * @param array $input * @return array */ 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; } }