* freigeben" (only after a fresh green report) and "Freigabe zurückziehen" (whenever a database release applies, * whatever the overall state). Loading the tab removes check pages a dead check left behind – in the page's load * hook (removeOrphans()), not while rendering. renderNotice() is the admin notice on every admin page while a * feature is unusable. */ final class DiviTab implements AdminTab { public const SLUG = 'divi'; public const RELEASE_ACTION = 'lit_mcp_divi_release'; public const WITHDRAW_ACTION = 'lit_mcp_divi_withdraw'; /** @var list Check pages removeOrphans() deleted in this request; render() reports them. */ private array $removed = []; /** * @param (Closure(): string)|null $version Installed Divi version ('' = none); null = DiviEnvironment. * @param (Closure(string): string)|null $stateKey null = ReleaseResolver::stateKey(). * @param FeatureOverview|null $overview null = a table without the affected tools. */ public function __construct( private ?CheckReportStore $reports = null, private ?ReleaseStore $releases = null, private ?ChangeLog $log = null, private ?AdminPostAction $action = null, private ?Closure $version = null, private ?Closure $stateKey = null, private ?FeatureOverview $overview = null ) { } public function slug(): string { return self::SLUG; } public function label(): string { return __('Divi', 'wp-lit-mcp'); } public function registerSettings(): void { } public function registerActions(): void { add_action('admin_post_' . self::RELEASE_ACTION, [$this, 'release']); add_action('admin_post_' . self::WITHDRAW_ACTION, [$this, 'withdraw']); add_action('load-' . SettingsPage::SCREEN_HOOK, [$this, 'removeOrphans']); $this->registerNotice(); } /** The admin notice for administrators while a Divi feature is unusable (registerActions() runs in admin only). */ public function registerNotice(): void { add_action('admin_notices', [$this, 'renderNotice']); } /** * Only for administrators, only with Divi active, only while a feature is not usable – and not on the Divi tab, * which shows the same table. Reads the cached matrix (D7), so no check runs on an ordinary admin page. */ public function renderNotice(): void { if (! current_user_can('manage_options') || $this->version() === '' || $this->onDiviTab()) { return; } $unusable = ReleaseGate::status()->matrix->unusable(); if ($unusable === []) { return; } printf( '

%s %s

', esc_html(sprintf( /* translators: 1: Divi version, 2: comma separated feature names */ __( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'LIT MCP: Mit Divi %1$s sind diese Fähigkeiten nicht nutzbar: %2$s. Die betroffenen Werkzeuge lehnen ab, alle anderen laufen weiter.', 'wp-lit-mcp' ), $this->version(), implode(', ', array_map( static fn (FeatureStatus $status): string => $status->feature->label(), $unusable )) )), esc_url(SettingsPage::url(self::SLUG)), esc_html__('Details im Reiter „Divi“', 'wp-lit-mcp') ); } private function onDiviTab(): bool { $screen = function_exists('get_current_screen') ? get_current_screen() : null; return is_object($screen) && $screen->id === SettingsPage::SCREEN_HOOK && $this->requested(); } /** * load- of the settings page, before any output: on this tab, for manage_options and with Divi active, * deletes the check pages a dead check left behind (WriteProbe::removeOrphans(): marked drafts with the check * title, older than ten minutes – no running check uses them). On a GET without nonce on purpose: it removes * only the plugin's own leftovers and is idempotent. render() only reports what was deleted. */ public function removeOrphans(): void { if (! $this->requested() || ! current_user_can('manage_options') || $this->version() === '') { return; } $this->removed = WriteProbe::removeOrphans(); } public function release(): void { $action = $this->action ?? new AdminPostAction(); $action->guard(); check_admin_referer(self::RELEASE_ACTION); $version = $this->version(); $report = $this->reports()->latest(); $fresh = $report !== null && $report->freshFor($version, Plugin::VERSION, $this->stateKey($version)); if ($version === '' || ! $fresh) { $action->redirect(self::SLUG, 'divi_release_refused'); return; } ($this->releases ?? new ReleaseStore())->add($version, Plugin::VERSION, get_current_user_id(), $report->id); ReleaseGate::invalidate(); $this->log?->record( 'lit/divi-release', 'divi', $version, sprintf('Divi %s für LIT MCP %s freigegeben (Prüfbericht %s).', $version, Plugin::VERSION, $report->id) ); $action->redirect(self::SLUG, 'divi_released'); } public function withdraw(): void { $action = $this->action ?? new AdminPostAction(); $action->guard(); check_admin_referer(self::WITHDRAW_ACTION); $version = $this->version(); if (! ($this->releases ?? new ReleaseStore())->withdraw($version, Plugin::VERSION)) { $action->redirect(self::SLUG, 'divi_withdraw_failed'); return; } ReleaseGate::invalidate(); $this->log?->record( 'lit/divi-release', 'divi', $version, sprintf('Freigabe von Divi %s zurückgezogen.', $version) ); $action->redirect(self::SLUG, 'divi_withdrawn'); } public function render(): void { if ($this->version() === '') { echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text. 'Divi ist auf dieser Website nicht aktiv; die Divi-Freigabe ist ohne Bedeutung.', 'wp-lit-mcp' ) . '

'; return; } $status = ReleaseGate::status(); $report = $this->reports()->latest(); echo '

' . esc_html__('Divi-Freigabe', 'wp-lit-mcp') . '

'; echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'LIT MCP prüft nach einem Divi-Update jede Fähigkeit einzeln (z. B. „Seiten schreiben“). Ist sie nicht nutzbar, lehnen nur die Werkzeuge ab, die sie brauchen; alle anderen laufen weiter.', 'wp-lit-mcp' ) . '

'; if ($this->removed !== []) { printf( '

%s

', esc_html(sprintf( /* translators: %s: comma separated page IDs */ __('Liegengebliebene Prüfseiten gelöscht: %s', 'wp-lit-mcp'), implode(', ', $this->removed) )) ); } $this->renderStatus($status); $this->renderFeatures($status); $this->renderForms($status, $report); if ($report !== null) { $this->renderReport($report); } } private function renderStatus(ReleaseStatus $status): void { echo ''; printf( '', esc_html__('Divi-Version', 'wp-lit-mcp'), esc_html($status->version === '' ? __('nicht erkannt', 'wp-lit-mcp') : $status->version) ); printf( '', esc_html__('Gesamtzustand', 'wp-lit-mcp'), esc_html($this->stateLabel($status->state)), esc_html($this->explanation($status)) ); if ($status->problems !== []) { echo ''; } echo ''; } private function renderFeatures(ReleaseStatus $status): void { echo '

' . esc_html__('Fähigkeiten', 'wp-lit-mcp') . '

'; echo ''; $headings = [ __('Fähigkeit', 'wp-lit-mcp'), __('Zustand', 'wp-lit-mcp'), __('Quelle', 'wp-lit-mcp'), __('Probleme', 'wp-lit-mcp'), __('Betroffene Werkzeuge', 'wp-lit-mcp'), ]; foreach ($headings as $heading) { echo ''; } echo ''; foreach (($this->overview ?? FeatureOverview::withoutTools())->rows($status->matrix) as $row) { echo ''; echo ''; echo ''; echo ''; } echo '
' . esc_html($heading) . '
' . esc_html($row['label']) . '' . esc_html($this->stateLabel(ReleaseState::from($row['state']))) . '' . esc_html($this->sourceLabel($row['source'])) . ''; $this->renderList($row['problems'], false); echo ''; $this->renderList($row['tools'], true); echo '
'; $this->renderAutomationNote(); } /** @param list $items */ private function renderList(array $items, bool $code): void { if ($items === []) { echo '–'; return; } echo '
    '; foreach ($items as $item) { echo $code ? '
  • ' . esc_html($item) . '
  • ' : '
  • ' . esc_html($item) . '
  • '; } echo '
'; } private function sourceLabel(?string $source): string { return match ($source) { ReleaseStatus::SOURCE_CODE => __('Plugin (code)', 'wp-lit-mcp'), ReleaseStatus::SOURCE_DATABASE => __('Freigabe auf dieser Website (database)', 'wp-lit-mcp'), ReleaseStatus::SOURCE_AUTO => __('Automatik (auto)', 'wp-lit-mcp'), default => '–', }; } /** E1: what the automatic release does and how to switch it off. */ private function renderAutomationNote(): void { echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Automatik: Ist die installierte Divi-Version im Plugin nicht als getestet hinterlegt, gibt LIT MCP jede Fähigkeit einzeln frei, wenn alle benutzten Divi-Funktionen vorhanden sind, ihre Aufrufe (Signaturen) und die Fingerabdrücke nachgebauter Divi-Abläufe unverändert sind und alle Konstanten definiert sind. Das prüft Aufrufformen, nicht das Verhalten von Divi. Fehlt für eine Stelle die Referenz, ist die Fähigkeit ungetestet; weicht etwas ab, ist sie inkompatibel.', 'wp-lit-mcp' ) . '

'; echo '

' . sprintf( /* translators: %s: filter name in a code element */ esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Die Automatik abschalten: Filter %s auf false setzen (z. B. in einem mu-Plugin). Dann gelten nicht hinterlegte Divi-Versionen bis zur Freigabe unten als ungetestet.', 'wp-lit-mcp' ), 'lit_mcp_divi_auto_release' ) . '

'; } private function stateLabel(ReleaseState $state): string { return match ($state) { ReleaseState::Released => __('freigegeben', 'wp-lit-mcp'), ReleaseState::Untested => __('ungetestet', 'wp-lit-mcp'), ReleaseState::Incompatible => __('inkompatibel', 'wp-lit-mcp'), }; } private function explanation(ReleaseStatus $status): string { if ($status->state === ReleaseState::Incompatible) { return __( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Divi hat Stellen geändert, die LIT MCP nutzt. Die betroffenen Fähigkeiten (Tabelle unten) bleiben gesperrt, bis ein Plugin-Update von LIT MCP eingespielt ist; alle anderen laufen weiter.', 'wp-lit-mcp' ); } if ($status->state === ReleaseState::Untested) { return __( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Diese Version ist nicht geprüft. Die Werkzeuge der ungetesteten Fähigkeiten (Tabelle unten) lehnen ab, bis die Version freigegeben ist; alle anderen laufen weiter.', 'wp-lit-mcp' ); } if ($status->source === ReleaseStatus::SOURCE_CODE) { return __('Diese Version ist im Plugin als getestet hinterlegt.', 'wp-lit-mcp'); } if ($status->source === ReleaseStatus::SOURCE_AUTO) { return __( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Automatisch freigegeben: Alle Divi-Stellen, die LIT MCP nutzt, stimmen mit der geprüften Referenz überein.', 'wp-lit-mcp' ); } $user = get_userdata((int) ($status->release['user_id'] ?? 0)); $releasedAt = (int) strtotime((string) ($status->release['released_at'] ?? '')); return sprintf( /* translators: 1: date, 2: user name */ __('Auf dieser Website freigegeben am %1$s von %2$s.', 'wp-lit-mcp'), date_i18n((string) get_option('date_format'), $releasedAt), is_object($user) && isset($user->display_name) ? (string) $user->display_name : '?' ); } private function renderForms(ReleaseStatus $status, ?CheckReport $report): void { $fresh = $report !== null && $report->freshFor($status->version, Plugin::VERSION, $this->stateKey($status->version)); $untested = $status->state === ReleaseState::Untested && $status->version !== ''; printf('
', esc_url(DiviCheckRoute::url())); printf('', esc_attr(wp_create_nonce('wp_rest'))); // The check is the one thing to do while untested without a usable report; otherwise the release leads. submit_button( __('Prüfung starten', 'wp-lit-mcp'), $untested && ! $fresh ? 'primary' : 'secondary', 'lit-mcp-divi-check', false ); echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Die Prüfung legt eine private Testseite an, prüft Lesen und Schreiben und löscht sie wieder. Sie dauert einige Sekunden.', 'wp-lit-mcp' ) . '

'; echo '
'; if ($untested) { if ($fresh) { $this->actionForm( self::RELEASE_ACTION, /* translators: %s: Divi version */ sprintf(__('Divi %s freigeben', 'wp-lit-mcp'), $status->version), 'primary' ); } else { echo '

' . esc_html__( // phpcs:ignore Generic.Files.LineLength.TooLong -- WordPress.WP.I18n forbids splitting the text argument. 'Freigeben geht erst nach einer vollständig grünen Prüfung dieser Divi-Version – Prüfung erneut ausführen.', 'wp-lit-mcp' ) . '

'; } } // A database release applies whatever the overall state: one incompatible feature must not hide the way back. if ($status->source === ReleaseStatus::SOURCE_DATABASE || $status->release !== null) { $this->actionForm(self::WITHDRAW_ACTION, __('Freigabe zurückziehen', 'wp-lit-mcp'), 'secondary'); } } private function actionForm(string $action, string $label, string $type): void { printf('
', esc_url(admin_url('admin-post.php'))); printf('', esc_attr($action)); wp_nonce_field($action); submit_button($label, $type, 'submit', false); echo '
'; } private function renderReport(CheckReport $report): void { printf( '

%s

%s

    ', esc_html__('Letzte Prüfung', 'wp-lit-mcp'), esc_html(sprintf( /* translators: 1: Divi version, 2: plugin version, 3: date */ __('Divi %1$s mit LIT MCP %2$s, %3$s', 'wp-lit-mcp'), $report->diviVersion, $report->pluginVersion, date_i18n((string) get_option('date_format'), (int) strtotime($report->createdAt)) )) ); foreach ($report->steps as $step) { $mark = $step->skipped ? '–' : ($step->green() ? '✓' : '✗'); printf('
  • %s %s', esc_html($mark), esc_html($step->label())); if ($step->skipped) { echo ' (' . esc_html__('nicht ausgeführt', 'wp-lit-mcp') . ')'; } if ($step->problems !== []) { echo '
      '; foreach ($step->problems as $problem) { echo '
    • ' . esc_html($problem) . '
    • '; } echo '
    '; } echo '
  • '; } echo '

' . esc_html__( 'Optional: eine Divi-Seite im Visual Builder öffnen und speichern.', 'wp-lit-mcp' ) . '

'; } /** Whether this request opens this tab of the settings page (the load hook fires for every tab). */ private function requested(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only tab switch. $tab = isset($_GET['tab']) ? sanitize_key(wp_unslash((string) $_GET['tab'])) : ''; return $tab === self::SLUG; } private function version(): string { return ($this->version ?? static fn (): string => DiviEnvironment::version() ?? '')(); } private function stateKey(string $version): string { return ($this->stateKey ?? static fn (string $v): string => ReleaseResolver::stateKey($v))($version); } private function reports(): CheckReportStore { return $this->reports ?? new CheckReportStore(); } }