diff --git a/docs/guides/divi-shortform.md b/docs/guides/divi-shortform.md index e6a2155..1c9fb1b 100644 --- a/docs/guides/divi-shortform.md +++ b/docs/guides/divi-shortform.md @@ -86,7 +86,10 @@ ist dort das Admin-Label). Beim Einfügen über `attrs` (`fieldItem.advanced.typ den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`. Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B. -`nodes[0].rows[0].columns`); dann wurde nichts gespeichert. +`nodes[0].rows[0].columns`); dann wurde nichts gespeichert. Eine unbekannte Pfad-ID meldet `lit-divi-update` +mit den übrigen Eingabefehlern gesammelt als `validation_failed` (`fields.changes[i].id`), `lit-divi-move`, +`lit-divi-delete` und `lit/divi-duplicate-node` als `not_found` – in beiden Fällen mit `lit-divi-outline` +neu lesen. ## Spaltenbreiten ändern diff --git a/docs/guides/overview.md b/docs/guides/overview.md index c0e55c1..28791d1 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -47,7 +47,8 @@ Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, - Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand - des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt). + des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt); + `lit/divi-replace-text` nennt `tree_rev` je Seite in `results` (Probelauf: in `matches`). - Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`), `message`, `hint` (und ggf. `fields`). Dem `hint` folgen. - `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen. diff --git a/readme.txt b/readme.txt index 39c2b3d..23b7f56 100644 --- a/readme.txt +++ b/readme.txt @@ -2,7 +2,7 @@ Contributors: loosenit Tags: mcp, claude, divi, pods, ai Requires at least: 6.9 -Tested up to: 7.0 +Tested up to: 7.1 Requires PHP: 8.1 Stable tag: 0.3.0 License: GPL-2.0-or-later @@ -40,7 +40,8 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll, sich unter Einstellungen → LIT MCP → Tools ändern. * Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version, Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup, - Transient lit_mcp_endpoint_check. + Transient lit_mcp_endpoint_check, Benutzer-Meta lit_mcp_log_per_page (Ansichtsoption „Einträge pro Seite“ + im Protokoll). * Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post, @@ -103,7 +104,8 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä * Deaktivieren entfernt den Cron-Job und lässt WordPress die Rewrite-Regeln neu erzeugen; der MCP-Endpunkt und die OAuth-Adressen sind danach nicht mehr erreichbar. Einstellungen und Protokoll bleiben erhalten. -* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website). +* Löschen entfernt zusätzlich alle eigenen Optionen und die Protokolltabelle (auf Multisite je Website) + sowie die Ansichtsoption „Einträge pro Seite“ des Protokolls bei allen Benutzern. * Nicht entfernt werden die Daten von mcp-oauth (Option mcp_jwt_secret, Option wpmedia_mcp_oauth_rewrite_version, Benutzer-Meta mcp_refresh_jti_*, Anwendungspasswörter der Verbindungen), weil diese Bibliothek auch von anderen Plugins genutzt werden kann. Verbindungen @@ -137,8 +139,16 @@ Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie {{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten direkt mit Divi-Inhalt. -* Tools nach Gruppen einstellbar (direkt/indirekt/deaktiviert, „nur lesen“ je Gruppe); überarbeitete - Einstellungsseite. +* Tools nach Gruppen einstellbar (direkt/indirekt/deaktiviert, „nur lesen“ je Gruppe; gilt auch für + Divi-Inhalt über lit-content-create); überarbeitete Einstellungsseite, Protokoll als WordPress-Liste mit + Filtern, Zugriff mit „Zuletzt genutzt“ je Verbindung. +* OAuth-Discovery: /.well-known/oauth-* antwortet direkt statt per Weiterleitung (wichtig bei Permalinks mit + Schrägstrich am Ende); nicht freigegebene Benutzer können keine Verbindung mehr herstellen. +* Sicherheit: kein Divi-Markup über content, eigene Prüfung privater Adressbereiche beim Import per URL + (auch bei Weiterleitungen) mit neutraler Fehlermeldung, Pods-Feldrechte, kein direkter Aufruf der + PHP-Dateien. +* Kurzform: level auch für Kontaktformular, CTA, Toggle, Akkordeon, Slide, Zähler und Teammitglied; + lit/menu-create liefert menu_rev. = 0.2.0 = * WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und diff --git a/src/Integrations/Divi/Pages/DiviPageLoader.php b/src/Integrations/Divi/Pages/DiviPageLoader.php index 20c2fce..57ceb6d 100644 --- a/src/Integrations/Divi/Pages/DiviPageLoader.php +++ b/src/Integrations/Divi/Pages/DiviPageLoader.php @@ -18,10 +18,9 @@ defined('ABSPATH') || exit; /** * Loads an entry for the Divi tools: rights and status via PostGuard (spec §4.5), then the * block tree. Divi 4 shortcode content and unbalanced markup are refused with a hint – - * spec §3.4: Divi 4 content is kept, never edited. Not final so abilities can be tested - * with a mock. + * spec §3.4: Divi 4 content is kept, never edited. */ -class DiviPageLoader +final class DiviPageLoader { public const OUTLINE_HINT = 'Aktuellen Stand mit lit/divi-outline (direkt: lit-divi-outline) neu lesen und die ' . 'Änderung mit der neuen tree_rev wiederholen.'; diff --git a/src/Integrations/Divi/Pages/DiviWriter.php b/src/Integrations/Divi/Pages/DiviWriter.php index 8cb015e..42a1e11 100644 --- a/src/Integrations/Divi/Pages/DiviWriter.php +++ b/src/Integrations/Divi/Pages/DiviWriter.php @@ -23,10 +23,9 @@ defined('ABSPATH') || exit; * (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the * tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver, * then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline, - * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. Not final so abilities - * can be tested with a mock. + * quality warnings (spec §5.5), revision_id, the new rev and tree_rev. */ -class DiviWriter +final class DiviWriter { public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit ' . 'lit-divi-outline lesen.'; @@ -89,6 +88,7 @@ class DiviWriter ->rev(Rev::ofPost($post)) ->with('tree_rev', $page->treeRev()) ->warnings($change->warnings()) + ->urls((string) get_preview_post_link($post), $page->editUrl()) ->toArray(); } @@ -143,7 +143,8 @@ class DiviWriter /** * Outline of the sections that contain changed nodes (breadth limit of ruling S18). A removed * node has no block any more, but its ID from before the removal still names the (surviving) - * section. + * section – unless the removed node was that section itself: its ID now names the next section, + * which did not change (review m-7). * * @return array{text: ?string, truncated: bool} */ @@ -152,7 +153,8 @@ class DiviWriter $sections = []; foreach ($change->entries() as $entry) { $id = DiviChange::idOf($entry, $index); - if ($id !== null) { + $wholeSection = $entry['block'] === null && $id !== null && ! str_contains($id, '.'); + if ($id !== null && ! $wholeSection) { $sections[explode('.', $id)[0]] = true; } } diff --git a/src/Integrations/WordPress/Content/ContentCreateAbility.php b/src/Integrations/WordPress/Content/ContentCreateAbility.php index a2119ef..05b022e 100644 --- a/src/Integrations/WordPress/Content/ContentCreateAbility.php +++ b/src/Integrations/WordPress/Content/ContentCreateAbility.php @@ -34,6 +34,10 @@ defined('ABSPATH') || exit; */ final class ContentCreateAbility implements Ability { + /** The extras (written after the layout) re-saved the entry and changed the stored layout (review m-6). */ + public const LAYOUT_CHANGED = 'Der Divi-Inhalt wurde beim Speichern der Felder verändert (z. B. durch ein Plugin); ' + . 'tree_rev beschreibt den gespeicherten Stand – vor weiteren Änderungen lit-divi-outline lesen.'; + public function __construct( private ContentSchema $schema, private PostWriter $writer, @@ -161,6 +165,7 @@ final class ContentCreateAbility implements Ability return $draft->refuse($postId, $layout); } + $stored = is_array($layout) ? self::content($postId) : null; try { $this->writer->writeExtras($postId, $postType, $prepared, $input); } catch (Throwable $e) { @@ -168,6 +173,9 @@ final class ContentCreateAbility implements Ability } $warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []); + if ($stored !== null && self::content($postId) !== $stored) { + $warnings[] = self::LAYOUT_CHANGED; + } return $this->result($postId, $postType, $input, $warnings, $layout); } @@ -188,6 +196,13 @@ final class ContentCreateAbility implements Ability return $this->divi?->check($postType, self::sections($input)); } + private static function content(int $postId): ?string + { + $post = get_post($postId); + + return $post instanceof WP_Post ? (string) $post->post_content : null; + } + /** * @param array $input * @return list diff --git a/src/Integrations/WordPress/Content/NewDraft.php b/src/Integrations/WordPress/Content/NewDraft.php index 3cf641e..317a69a 100644 --- a/src/Integrations/WordPress/Content/NewDraft.php +++ b/src/Integrations/WordPress/Content/NewDraft.php @@ -106,7 +106,7 @@ final class NewDraft static fn () => self::deleteDraft($postId) ); - $detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage()); + $detail = sprintf('Writing new post %d failed after the insert: %s', $postId, $cause->getMessage()); if ($problems === []) { throw WriteFailed::rolledBack($reason, $cause, $detail); } diff --git a/src/Limits.php b/src/Limits.php index 65fcb6f..5d34cfb 100644 --- a/src/Limits.php +++ b/src/Limits.php @@ -48,7 +48,7 @@ final class Limits return $this->all()['log_retention_days']; } - /** Reserved for design-system snapshots (Etappe 3); LimitsTab keeps the field hidden until then. */ + /** Reserved for design-system snapshots (Etappe 4, spec §12); LimitsTab keeps the field hidden until then. */ public function snapshotCount(): int { return $this->all()['snapshot_count']; diff --git a/src/Uninstaller.php b/src/Uninstaller.php index 3cf62a6..fe70731 100644 --- a/src/Uninstaller.php +++ b/src/Uninstaller.php @@ -9,14 +9,15 @@ defined('ABSPATH') || exit; /** * Removes WP-LIT-MCP's own data (uninstall.php, Task 12): options, the endpoint-check * transient, the log-cleanup cron hook and the change log table – on every site of a - * network. + * network – and the Protokoll tab's per-user screen option. * * Deliberately self-contained (no reference to any other LIT\MCP\* class): uninstall.php * is executed by WordPress core standalone, without loading the plugin's main file first, * so the Composer/Jetpack autoloader is not guaranteed to have run. The option/hook/table * names below are therefore duplicated from Access::OPTION, Abilities\ExposurePolicy::OPTION, * Limits::OPTION, ChangeLog\ChangeLogInstaller::VERSION_OPTION, Admin\ConnectionStatus:: - * ENDPOINT_TRANSIENT, ChangeLog\ChangeLogCleanup::HOOK and ChangeLog\ChangeLog::TABLE on + * ENDPOINT_TRANSIENT, ChangeLog\ChangeLogCleanup::HOOK, ChangeLog\ChangeLog::TABLE and + * Admin\LogTab::PER_PAGE_OPTION on * purpose; tests/Unit/UninstallerTest.php and tests/Unit/UninstallTest.php keep them in * sync with those constants. * @@ -32,8 +33,14 @@ final class Uninstaller private const CRON_HOOK = 'lit_mcp_log_cleanup'; private const TABLE = 'lit_mcp_log'; + /** Screen option "Einträge pro Seite" of the Protokoll tab (Admin\LogTab::PER_PAGE_OPTION): user meta. */ + private const USER_META = 'lit_mcp_log_per_page'; + public static function run(): void { + // User meta is shared by every site of a network: removed once, for all users. + delete_metadata('user', 0, self::USER_META, '', true); + if (! is_multisite()) { self::removeSiteData(); diff --git a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php index 0eda188..772cf8a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php @@ -105,10 +105,43 @@ final class DiviWriterTest extends TestCase $writer = $this->writer($saver, FakeChangeLog::forbidden()); $page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []); self::assertSame(Rev::of($content), $result['tree_rev']); self::assertStringStartsWith('Keine Änderung', $result['summary']); + // Review m-1: the same result shape as a save, URLs included. + self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']); + self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']); + } + + /** Review m-7: a deleted whole section has no section left to outline (its ID now names the next one). */ + public function testDeletingAWholeSectionOutlinesNothing(): void + { + $content = DiviFixtures::markup('synthetic-basic.html'); + DiviFixtures::givenPost($content); + $saver = Mockery::mock(DiviSaver::class); + $saver->allows('save')->andReturnUsing(static function (WP_Post $post, string $new): array { + DiviFixtures::givenPost($new); + + return ['revision_id' => 811, 'warnings' => []]; + }); + $input = ['post_id' => 7, 'tree_rev' => Rev::of($content)]; + Functions\when('do_action')->justReturn(null); + Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true'); + $writer = $this->writer($saver, new FakeChangeLog(55)); + $page = $writer->page($input); + $section = $page->index->find('s0'); + $page->document->children()[0]->removeChild($section); + + $result = $writer->save('lit/divi-delete', $page, (new DiviChange('gelöscht'))->removed('s0', $section), $input); + + self::assertNull($result['outline']); + self::assertSame([['s0', 'divi/section', 'deleted']], array_map( + static fn (array $c): array => [$c['id'], $c['type'], $c['action']], + $result['changed'] + )); } public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void diff --git a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php index f8e38b0..7ff7694 100644 --- a/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php +++ b/tests/Unit/Integrations/WordPress/Content/ContentCreateDiviTest.php @@ -123,6 +123,46 @@ final class ContentCreateDiviTest extends TestCase self::assertArrayHasKey('divi', $this->ability(null)->inputSchema()['properties']); } + /** + * Review m-6: the extras are written after the layout; if that re-saves the entry (e.g. Pods + * object fields call wp_update_post, a save_post listener) and the layout changes, say so. + */ + public function testALayoutChangedByTheExtrasIsReported(): void + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(['warnings' => [], 'outline' => 's0 divi/section']); + Functions\when('wp_insert_post')->justReturn(42); + $this->stubStoredDraft(); + $writer = Mockery::mock(PostWriter::class); + $writer->allows('prepare')->andReturn(['fields' => [], 'terms' => [], 'warnings' => []]); + $writer->allows('postData')->andReturn(['post_title' => 'Start']); + $writer->allows('deniedMeta')->andReturn([]); + $writer->expects('writeExtras')->andReturnUsing(static function (): void { + Functions\when('get_post')->justReturn(new WP_Post([ + 'ID' => 42, 'post_title' => 'Start', 'post_type' => 'page', 'post_status' => 'draft', + 'post_content' => '', + ])); + }); + + $result = $this->ability($composer, $writer)->execute(self::INPUT + ['fields' => ['role' => 'x']]); + + self::assertSame(Rev::of(''), $result['tree_rev'], 'the stored state'); + self::assertContains(ContentCreateAbility::LAYOUT_CHANGED, $result['warnings']); + + $this->stubStoredDraft(); + $unchanged = $this->ability($this->composerWriting(), $this->writer())->execute(self::INPUT); + self::assertNotContains(ContentCreateAbility::LAYOUT_CHANGED, $unchanged['warnings']); + } + + /** @return ContentComposer&MockInterface */ + private function composerWriting(): ContentComposer + { + $composer = $this->composer(); + $composer->allows('write')->andReturn(['warnings' => [], 'outline' => null]); + + return $composer; + } + public function testWithoutDiviTheComposerIsNotUsed(): void { $composer = Mockery::mock(ContentComposer::class); diff --git a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php index 632e876..3c749a5 100644 --- a/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php +++ b/tests/Unit/Integrations/WordPress/Content/NewDraftTest.php @@ -33,7 +33,7 @@ final class NewDraftTest extends TestCase self::assertSame(42, $e->postId); self::assertSame('Grund für den Client.', $e->reason); self::assertSame( - 'Extras of new post 42 could not be written: layout kaputt; rollback: term 15 bleibt; ' + 'Writing new post 42 failed after the insert: layout kaputt; rollback: term 15 bleibt; ' . 'draft 42 could not be deleted: delete_post hook kaputt [rollback incomplete]', $e->getMessage() ); diff --git a/tests/Unit/UninstallTest.php b/tests/Unit/UninstallTest.php index 03cd509..a370a91 100644 --- a/tests/Unit/UninstallTest.php +++ b/tests/Unit/UninstallTest.php @@ -35,6 +35,7 @@ final class UninstallTest extends TestCase Functions\expect('restore_current_blog')->twice(); Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + Functions\expect('delete_metadata')->once()->with('user', 0, 'lit_mcp_log_per_page', '', true); $deleted = []; Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool { $deleted[] = $option; diff --git a/tests/Unit/UninstallerTest.php b/tests/Unit/UninstallerTest.php index e23435b..df5f23b 100644 --- a/tests/Unit/UninstallerTest.php +++ b/tests/Unit/UninstallerTest.php @@ -8,6 +8,7 @@ use Brain\Monkey\Functions; use LIT\MCP\Abilities\ExposurePolicy; use LIT\MCP\Access; use LIT\MCP\Admin\ConnectionStatus; +use LIT\MCP\Admin\LogTab; use LIT\MCP\ChangeLog\ChangeLogCleanup; use LIT\MCP\ChangeLog\ChangeLogInstaller; use LIT\MCP\Limits; @@ -37,6 +38,8 @@ final class UninstallerTest extends TestCase Functions\expect('restore_current_blog')->never(); Functions\expect('delete_transient')->once()->with(ConnectionStatus::ENDPOINT_TRANSIENT); Functions\expect('wp_clear_scheduled_hook')->once()->with(ChangeLogCleanup::HOOK); + // Review m-8: the Protokoll screen option is user meta (network-wide): removed once. + Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); $deleted = []; Functions\when('delete_option')->alias(static function (string $option) use (&$deleted): bool { $deleted[] = $option; @@ -62,6 +65,7 @@ final class UninstallerTest extends TestCase Functions\expect('restore_current_blog')->twice(); Functions\expect('delete_transient')->twice()->with(ConnectionStatus::ENDPOINT_TRANSIENT); Functions\expect('wp_clear_scheduled_hook')->twice()->with(ChangeLogCleanup::HOOK); + Functions\expect('delete_metadata')->once()->with('user', 0, LogTab::PER_PAGE_OPTION, '', true); Functions\when('delete_option')->justReturn(true); Uninstaller::run(); diff --git a/wp-lit-mcp.php b/wp-lit-mcp.php index 8c8ed45..c91a133 100644 --- a/wp-lit-mcp.php +++ b/wp-lit-mcp.php @@ -6,7 +6,7 @@ * Version: 0.3.0 * Requires at least: 6.9 * Requires PHP: 8.1 - * Tested up to: 7.0 + * Tested up to: 7.1 * Author: Jan-Niclas Loosen - Loosen-IT * Author URI: https://loosen-it.de * License: GPL-2.0-or-later